├── .gitignore ├── README.md ├── app ├── lib │ ├── attempt-repeatedly.ts │ ├── await-element.ts │ ├── delay.ts │ ├── find-dom-ancestor.ts │ ├── first-regex-match-or-null.ts │ ├── html-util.ts │ ├── multi-try-delayed.ts │ ├── path-util.ts │ ├── prompt-download-util.ts │ ├── stringify-request-body.ts │ ├── url-to-filename.test.ts │ └── url-to-filename.ts └── src │ ├── background.ts │ ├── carousel-index-observer.ts │ ├── data-extraction │ ├── directly-in-browser │ │ ├── carousel │ │ │ ├── carousel-index.ts │ │ │ ├── carousel-item.ts │ │ │ ├── carousel-media.ts │ │ │ ├── collection-details.ts │ │ │ └── indicator-dots.ts │ │ ├── general-post-info │ │ │ ├── post-href.ts │ │ │ ├── post-type.ts │ │ │ └── post-username.ts │ │ ├── media-and-src │ │ │ ├── media-extraction.ts │ │ │ ├── query-media-and-get-src.ts │ │ │ ├── query-media-element.ts │ │ │ ├── src-from-img-or-video.ts │ │ │ └── srcset-util.ts │ │ ├── media-id.ts │ │ ├── own-username.ts │ │ ├── shortcode-web-info │ │ │ ├── find-deep-property.ts │ │ │ ├── parse-script-json.ts │ │ │ ├── shortcode-from-url.ts │ │ │ └── shortcode_media_script.ts │ │ ├── social-media-posting │ │ │ ├── carousel-video-index.ts │ │ │ ├── find-in-dom.ts │ │ │ ├── media-provider.ts │ │ │ └── types.ts │ │ ├── stories │ │ │ ├── main-element.ts │ │ │ ├── source.ts │ │ │ ├── story-id.ts │ │ │ ├── story-index.ts │ │ │ ├── story-type.ts │ │ │ └── username.ts │ │ └── try-get-image-src.ts │ ├── from-fetch-response │ │ ├── cached-media-fetching.ts │ │ ├── fetch-media-data.ts │ │ ├── find-carousel-item.ts │ │ ├── media-id.ts │ │ ├── response-data-type.ts │ │ ├── types.ts │ │ ├── video-dash-manifest.test.ts │ │ └── video-dash-manifest.ts │ ├── hybrid │ │ ├── cached-media-fetching.ts │ │ ├── media-id-of-post.ts │ │ └── media-of-post.ts │ ├── instagram-api │ │ ├── media-info.ts │ │ ├── request-header-collection │ │ │ ├── foreground-collector.ts │ │ │ ├── media-id-collector.ts │ │ │ └── web-request-listener.ts │ │ ├── stories │ │ │ ├── main-feed.ts │ │ │ ├── user-stories-highlights.ts │ │ │ └── user-stories-main.ts │ │ ├── url-maker.ts │ │ └── user-info.ts │ ├── is-currently-post-story-or-preview.ts │ └── media-types.ts │ ├── disk-writing │ ├── chrome-download-background.ts │ ├── chrome-download-types.ts │ ├── chrome-download.ts │ ├── disk-download-background.ts │ ├── disk-download.ts │ └── lookup-write-path.ts │ ├── download-button-injection │ ├── cached-story-fetching.ts │ ├── combined-download-extension.ts │ ├── post-button-injection.ts │ ├── preview-button-injection.ts │ └── story-extension.ts │ ├── download-buttons │ ├── disk-download-button.ts │ ├── download-feedback-button.ts │ ├── icon-url.ts │ ├── link-button.ts │ └── prompt-download-button.ts │ ├── download-shortcut.ts │ ├── index.ts │ ├── insta-navigation-observer.ts │ ├── media-fetch-fn.ts │ ├── mutation-observer-posts-previews-stories.ts │ ├── navigation-by-keys │ ├── find-current-post.ts │ ├── find-mainfeed-posts.ts │ ├── horizontal-navigation.ts │ ├── navigation-setup.ts │ └── vertical-navigation.ts │ ├── notifications-background.ts │ ├── options │ ├── options.html │ └── options.js │ ├── story-extension │ ├── linkify-stories.ts │ └── story-scroll-persistence.ts │ └── video-request-detection.ts ├── demo ├── download-button-on-main-feed.jpg ├── insta-loader-demo-1.gif ├── install.gif ├── install.mp4 ├── mainfeed-download.gif ├── mainfeed-download.mp4 ├── story-download.gif ├── story-download.mp4 ├── uninstall.gif ├── uninstall.mp4 ├── userpage-download.gif └── userpage-download.mp4 ├── dev ├── fiddling │ ├── dash-manifest-test.ts │ ├── index.html │ └── index.ts ├── icons │ ├── 36b3ee2d91ed.ico │ ├── 7bb4a992fbd2.png │ ├── credit.txt │ ├── download-arrows.png │ ├── instagram icon 192x192.png │ └── instagram icon 32x32.png ├── ideas.txt └── reference-data │ ├── feed-video-1 │ ├── additionalData.json │ ├── response.html │ └── source.txt │ ├── image-1 │ ├── additionalData.json │ ├── response.html │ └── source.txt │ ├── image-carousel-1 │ ├── additionalData.json │ ├── response.html │ └── source.txt │ ├── mixed-carousel-1 │ ├── additionalData.json │ ├── response.html │ └── source.txt │ ├── reel-video-1 │ ├── additionalData.json │ ├── response.html │ └── source.txt │ ├── sources.txt │ ├── tv-video-1 │ ├── additionalData.json │ ├── response.html │ └── source.txt │ ├── video-1 │ ├── additionalData.json │ ├── response.html │ └── source.txt │ ├── video-2 │ ├── additionalData.json │ ├── response.html │ └── source.txt │ └── video-carousel-1 │ ├── additionalData.json │ ├── dash-manifest-1.xml │ ├── response.html │ └── source.txt ├── dist ├── assets │ ├── background.ts.836b27b6.js │ ├── content-script-loader.index.ts.3e506a8d.037ffce3.js │ ├── icons │ │ ├── download-icon-black.png │ │ ├── download-icon-dark-3.png │ │ ├── download-icon-dark.png │ │ ├── download-icon-white.png │ │ ├── error.png │ │ ├── external-link-black.png │ │ ├── external-link-white.png │ │ ├── insta-loader-icon-128.png │ │ ├── insta-loader-icon-16.png │ │ ├── insta-loader-icon-192.png │ │ ├── insta-loader-icon-48.png │ │ ├── save-dark.png │ │ ├── save-white.png │ │ ├── save.png │ │ ├── spinner-of-dots dark.png │ │ ├── spinner-of-dots white.png │ │ ├── spinner-of-dots.png │ │ ├── verify-sign black.png │ │ ├── verify-sign dark.png │ │ └── verify-sign-green.png │ ├── index.ts.3e506a8d.js │ └── vendor.b289aac1.js ├── manifest.json └── service-worker-loader.js ├── host ├── checklist for native messaging.txt ├── linux_mac │ ├── insta_loader_host.json │ ├── insta_loader_host.py │ └── insta_loader_host_starter.sh └── windows │ ├── insta_loader_host.json │ ├── insta_loader_host.py │ ├── insta_loader_host_starter.bat │ ├── install_host.bat │ └── uninstall_host.bat ├── jest.config.js ├── manifest.json ├── manifest.schema.json ├── package-lock.json ├── package.json ├── public └── assets │ └── icons │ ├── download-icon-black.png │ ├── download-icon-dark-3.png │ ├── download-icon-dark.png │ ├── download-icon-white.png │ ├── error.png │ ├── external-link-black.png │ ├── external-link-white.png │ ├── insta-loader-icon-128.png │ ├── insta-loader-icon-16.png │ ├── insta-loader-icon-192.png │ ├── insta-loader-icon-48.png │ ├── save-dark.png │ ├── save-white.png │ ├── save.png │ ├── spinner-of-dots dark.png │ ├── spinner-of-dots white.png │ ├── spinner-of-dots.png │ ├── verify-sign black.png │ ├── verify-sign dark.png │ └── verify-sign-green.png ├── tsconfig.json └── vite.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dev -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # about 3 | 4 | a chrome extension to quickly download any media from [https://instagram.com/](instagram.com) 5 | 6 | this extension breaks occasionally (maybe once a month) due to Instagram updating their website. i do my best to repair it asap whenever that happens. 7 | 8 | so before you use this extension, you have to be willing to wait for a fix when something breaks and then [manually re-install the extension](#install). 9 | 10 | 11 | 12 | ## latest update 13 | 14 | October 26th 2024 15 | 16 | fixed loading of extension. 17 | it got broken in the recent chrome version 130. 18 | 19 | 20 | 21 | ## current limitations are: 22 | 23 | - only english browser settings are supported. for other languages, this extension may not work correctly, i.e. downloads can fail or the download button may not show up. 24 | this is due to the fact that i'm often using aria-label selectors and assuming the aria-labels to be english. 25 | moreover, check that the urls of your instagram page doesn't contain language parameters such as `?hl=de` (de is for german). if it does, you can remove that part and reload the page. 26 | 27 | - when you open a story, you won't be able to download the very first video/image right away. you will have to do at least one navigation (either click the 'next story' or 'previous story' button). 28 | 29 | - audio is missing from downloaded videos. the reason is that video- and audio parts on instagram are stored in separate files on their servers. when you press download, only the video-part is downloaded. merging the parts into one mp4-file is possible, but the code required will bloat this extension immensely (~20 megabytes). an alternative is to simply download both video and audio together. will have to think more about this. 30 | 31 | - video downloads sometimes fail due to a [limitation of chrome extensions to keep background scripts running](https://github.com/flurrux/insta-loader/issues/24#issuecomment-1159406256). 32 | if that's the case, please refresh the page and try again. 33 | if it still doesn't work, consider leaving [a bug report here](https://github.com/flurrux/insta-loader/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc). 34 | 35 | 36 |   37 | 38 | 39 | # features 40 | 41 | - download-buttons on mainfeed, stories and post pages 42 | - on the mainfeed or in stories, the enter-key can be pressed to trigger downloads 43 | 44 | 45 | downloaded videos/images will be saved in `Downloads/[username]`. 46 | so for example if you download an image from [instagram.com/beeple_crap](https://www.instagram.com/beeple_crap/), it will be placed in `Downloads/beeple_crap/` 47 | 48 | 49 |
50 | 51 | ### download from main-feed: 52 | 53 | 54 | 55 |
56 | 57 | ### download from stories: 58 | 59 | 60 | 61 |
62 | 63 | # install 64 | 65 | 66 | 67 | - click on the latest release to the right of this page and download the zip folder. it will be named something like `insta-loader-vx.x.x.zip` 68 | 69 | - extract this zip folder in your Downloads folder or anywhere else. 70 | 71 | - go to [chrome://extensions/](chrome://extensions/) 72 | 73 | - make sure developer mode is enabled 74 | 75 | - click "load unpacked" 76 | 77 | - pick the extracted folder 78 | 79 | - now when you visit [https://instagram.com/](instagram.com), you should see downlad buttons on every post like so: 80 | 81 | ![](./demo/download-button-on-main-feed.jpg) 82 | 83 | - lastly, make sure that your browser language is set to english or otherwise, the extension might not work correctly. 84 | 85 | 86 | # uninstall 87 | 88 | 89 | 90 | find this extension on [chrome://extensions/](chrome://extensions/), then click "Remove" -------------------------------------------------------------------------------- /app/lib/attempt-repeatedly.ts: -------------------------------------------------------------------------------- 1 | import { Lazy } from 'fp-ts/es6/function'; 2 | import { isSome, Option } from 'fp-ts/es6/Option'; 3 | import { waitMillis } from './delay'; 4 | 5 | export async function attemptRepeatedly(millisDelta: number, maxAttempt: number, attemptFunc: Lazy>): Promise { 6 | for (let i = 0; i < maxAttempt; i++) { 7 | const result = attemptFunc(); 8 | if (isSome(result)) return result.value; 9 | await waitMillis(millisDelta); 10 | } 11 | throw 'attempt was unsuccessful'; 12 | } -------------------------------------------------------------------------------- /app/lib/await-element.ts: -------------------------------------------------------------------------------- 1 | import { fromNullable, Option } from 'fp-ts/es6/Option'; 2 | import { attemptRepeatedly } from './attempt-repeatedly'; 3 | 4 | 5 | function querySelect(root: HTMLElement, query: string): Option { 6 | return fromNullable(root.querySelector(query)); 7 | } 8 | 9 | export async function waitForElementExistence(millisDelta: number, maxAttempt: number, root: HTMLElement, query: string): Promise { 10 | return attemptRepeatedly( 11 | millisDelta, maxAttempt, 12 | () => querySelect(root, query) 13 | ); 14 | } -------------------------------------------------------------------------------- /app/lib/delay.ts: -------------------------------------------------------------------------------- 1 | export function waitMillis(millis: number) { 2 | return new Promise((resolve) => setTimeout(resolve, millis)); 3 | } -------------------------------------------------------------------------------- /app/lib/find-dom-ancestor.ts: -------------------------------------------------------------------------------- 1 | import { Option, none, some } from "fp-ts/es6/Option"; 2 | import { Predicate } from "fp-ts/es6/Predicate"; 3 | 4 | export function findInAncestors(predicate: Predicate, element: HTMLElement): Option { 5 | let curElement = element; 6 | for (let i = 0; i < 1000; i++) { 7 | if (predicate(curElement)) { 8 | return some(curElement); 9 | } 10 | const nextElement = curElement.parentElement; 11 | if (!nextElement) return none; 12 | curElement = nextElement; 13 | } 14 | return none; 15 | } -------------------------------------------------------------------------------- /app/lib/first-regex-match-or-null.ts: -------------------------------------------------------------------------------- 1 | import { flow } from "fp-ts/es6/function"; 2 | import { none, some } from "fp-ts/es6/Option"; 3 | 4 | export function getFirstMatchOrNull(result: RegExpExecArray | null){ 5 | if (!result) return null; 6 | return result[0]; 7 | } 8 | 9 | export function firstRegexResult(result: RegExpExecArray | null){ 10 | if (!result) return none; 11 | return some(result[0]); 12 | } 13 | 14 | export const makeRegexFn = (regex: RegExp) => flow( 15 | (str: string) => regex.exec(str), 16 | firstRegexResult 17 | ) -------------------------------------------------------------------------------- /app/lib/html-util.ts: -------------------------------------------------------------------------------- 1 | export const createElementByHTML = (html: string): HTMLElement => { 2 | const wrapper = document.createElement("div") as HTMLDivElement; 3 | wrapper.innerHTML = html; 4 | return wrapper.firstElementChild as HTMLElement; 5 | }; 6 | 7 | export function querySelectorAncestor(query: string, el: HTMLElement): HTMLElement | null { 8 | let curParent = el; 9 | for (let i = 0; i < 1000; i++){ 10 | if (curParent.matches(query)){ 11 | return curParent; 12 | } 13 | const nextParent = curParent.parentElement; 14 | if (!nextParent) break; 15 | curParent = nextParent; 16 | } 17 | return null; 18 | } -------------------------------------------------------------------------------- /app/lib/multi-try-delayed.ts: -------------------------------------------------------------------------------- 1 | 2 | export function tryMultiAndDelayed(tryFunc: () => void, delay: number){ 3 | let timeoutID = -1; 4 | const tryDelayed = () => { 5 | if (timeoutID >= 0){ 6 | window.clearTimeout(timeoutID); 7 | } 8 | window.setTimeout( 9 | tryFunc, delay 10 | ); 11 | }; 12 | return tryDelayed; 13 | } -------------------------------------------------------------------------------- /app/lib/path-util.ts: -------------------------------------------------------------------------------- 1 | 2 | export const joinPaths = (path1: string, path2: string): string => { 3 | if (path1.endsWith("/")) return path1 + path2; 4 | return `${path1}/${path2}`; 5 | }; -------------------------------------------------------------------------------- /app/lib/prompt-download-util.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | export const promptDownload = (blobUrl: string, filename: string) => { 4 | const a = document.createElement('a'); 5 | a.download = filename; 6 | a.href = blobUrl; 7 | a.click(); 8 | }; 9 | 10 | // Current blob size limit is around 500MB for browsers 11 | export const downloadResource = async (url: string, filename: string) => { 12 | const response = await fetch(url, { 13 | headers: new Headers({'Origin': location.origin}), 14 | mode: 'cors' 15 | }); 16 | const blob = await response.blob(); 17 | const blobUrl = window.URL.createObjectURL(blob); 18 | promptDownload(blobUrl, filename); 19 | }; -------------------------------------------------------------------------------- /app/lib/stringify-request-body.ts: -------------------------------------------------------------------------------- 1 | import { map as mapArray } from "fp-ts/es6/Array"; 2 | import { pipe } from "fp-ts/es6/function"; 3 | import { map as mapRecord, toArray } from "fp-ts/es6/Record"; 4 | 5 | type InterceptedRequestBody = Record; 6 | 7 | export const stringifyRequestBody = (body: InterceptedRequestBody) => pipe( 8 | body, 9 | mapRecord( 10 | (values) => encodeURIComponent(values[0]) 11 | ), 12 | toArray, 13 | mapArray(([key, value]) => `${key}=${value}`), 14 | (array) => array.join("&") 15 | ); -------------------------------------------------------------------------------- /app/lib/url-to-filename.test.ts: -------------------------------------------------------------------------------- 1 | import { createFileNameByUrl } from "./url-to-filename"; 2 | import { right } from "fp-ts/es6/Either"; 3 | 4 | test( 5 | "extract the filename from a media-url", 6 | () => { 7 | expect( 8 | createFileNameByUrl( 9 | "https://instagram.fscn1-1.fna.fbcdn.net/v/t51.2885-15/257985981_434541238287510_1109862919694054404_n.jpg?stp=dst-jpg_e35&_nc_ht=instagram.fscn1-1.fna.fbcdn.net&_nc_cat=106&_nc_ohc=Y_4uVD77RVsAX-lX1vW&edm=AABBvjUBAAAA&ccb=7-4&ig_cache_key=MjcwNzgwODk5OTk0MjY5MDgzMA%3D%3D.2-ccb7-4&oh=00_AT_Rgq8bvbBtjb5tzFmB3SY7RGHOtxgY490WFKAF2RvWlg&oe=62372FB5&_nc_sid=83d603" 10 | ) 11 | ).toStrictEqual( 12 | right("257985981_434541238287510_1109862919694054404_n.jpg") 13 | ) 14 | } 15 | ); -------------------------------------------------------------------------------- /app/lib/url-to-filename.ts: -------------------------------------------------------------------------------- 1 | import { Either, left, right } from "fp-ts/es6/Either"; 2 | 3 | // todo: maybe move this module to some instagram-specific folder. 4 | 5 | /* 6 | this module is for extracting the filename-part of a url like this: 7 | 8 | https://instagram.fscn1-1.fna.fbcdn.net/v/t51.2885-15/257985981_434541238287510_1109862919694054404_n.jpg?stp=dst-jpg_e35&_nc_ht=instagram.fscn1-1.fna.fbcdn.net&_nc_cat=106&_nc_ohc=Y_4uVD77RVsAX-lX1vW&edm=AABBvjUBAAAA&ccb=7-4&ig_cache_key=MjcwNzgwODk5OTk0MjY5MDgzMA%3D%3D.2-ccb7-4&oh=00_AT_Rgq8bvbBtjb5tzFmB3SY7RGHOtxgY490WFKAF2RvWlg&oe=62372FB5&_nc_sid=83d603 9 | 10 | ↓ 11 | 12 | 257985981_434541238287510_1109862919694054404_n.jpg 13 | 14 | --- 15 | 16 | currently there are only 4 supported file-extensions: .mp4, .jpg, .webp, .webm 17 | i've revamped this module after discovering that instagram added .webp files and this led to downloaded images being named "undefined". 18 | it would certainly be more robust to find a regex for arbitrary file-extensions but i don't know how at this point. next time instagram adds another filetype maybe. 19 | 20 | --- 21 | 22 | note that all file-extension strings in this module include the leading dot: 23 | NOT "jpg" -> BUT ".jpg" 24 | */ 25 | 26 | 27 | // example: [".mp4", ".jpg", ".webp"] -> "\.mp4|\.jpg|\.webp" 28 | function makeFileExtensionRegexPart(fileExtensions: string[]): string { 29 | return fileExtensions.map(ext => `\\${ext}`).join("|"); 30 | } 31 | 32 | function makeFileNameRegex(fileExtensions: string[]){ 33 | // this regex looks for the longest string that doesn't contain a forward slash and ends in one of the file-extensions, like .jpg 34 | // [^/]*? means as few characters as possible that are not a forward slash 35 | // (?=${fileExtPart}) is a positive look-ahead for our fileExtensions 36 | const fileExtPart = makeFileExtensionRegexPart(fileExtensions); 37 | return new RegExp(`[^/]*(${fileExtPart})`); 38 | } 39 | 40 | function urlIncludesFileExtension(fileExtensions: string[], url: string) { 41 | const fileExtensionRegex = new RegExp(makeFileExtensionRegexPart(fileExtensions)); 42 | return fileExtensionRegex.exec(url) !== null; 43 | } 44 | 45 | const extractFileNameByRegexAndFileExtensionsAndUrl = (regex: RegExp, fileExtensions: string[]) => (url: string): Either => { 46 | const fileNameGroup = regex.exec(url); 47 | if (fileNameGroup !== null){ 48 | const fileName = fileNameGroup[0]; 49 | return right(fileName); 50 | } 51 | 52 | // at this point, we could not extract a filename for unknown reason. 53 | // let's check if the url contains any of the file-extensions: 54 | if (!urlIncludesFileExtension(fileExtensions, url)) { 55 | return left(`the url '${url}' does not contain any of the expected file-extensions: ${fileExtensions.join(", ")}`); 56 | } 57 | 58 | return left(`could not extract a filename from the url '${url}' although it appears to contain a valid file-extension.`); 59 | }; 60 | 61 | // this functions caches the regex so it doesn't have to be recreated everytime we download a file 62 | const makeFileNameExtractor = (fileExtensions: string[]) => { 63 | return extractFileNameByRegexAndFileExtensionsAndUrl( 64 | makeFileNameRegex(fileExtensions), 65 | fileExtensions 66 | ) 67 | }; 68 | 69 | // (url: string) => Either 70 | export const createFileNameByUrl = makeFileNameExtractor([".mp4", ".jpg", ".webp", ".webm", ".heic"]); -------------------------------------------------------------------------------- /app/src/background.ts: -------------------------------------------------------------------------------- 1 | import './disk-writing/chrome-download-background'; 2 | import './data-extraction/instagram-api/request-header-collection/web-request-listener'; -------------------------------------------------------------------------------- /app/src/carousel-index-observer.ts: -------------------------------------------------------------------------------- 1 | import { tryMultiAndDelayed } from "../lib/multi-try-delayed"; 2 | import { getCurrentCarouselIndexAndChildByList, IndexAndChild } from "./data-extraction/directly-in-browser/carousel/carousel-index"; 3 | 4 | 5 | type CarouselIndexChangeCallback = (newIndexAndChild: IndexAndChild) => void; 6 | 7 | export function observeCarouselIndex( 8 | listElement: HTMLUListElement, 9 | onIndexChanged: CarouselIndexChangeCallback){ 10 | 11 | let currentIndex = -1; 12 | const updateCurrentIndex = tryMultiAndDelayed( 13 | () => { 14 | const indexAndChild = getCurrentCarouselIndexAndChildByList(listElement); 15 | if (!indexAndChild) { 16 | console.warn("could not find index and child of carousel!"); 17 | return; 18 | } 19 | if (indexAndChild.index === currentIndex) return; 20 | currentIndex = indexAndChild.index; 21 | onIndexChanged(indexAndChild); 22 | }, 23 | 100 24 | ); 25 | 26 | const observer = new MutationObserver(updateCurrentIndex); 27 | observer.observe(listElement, { childList: true, subtree: true }); 28 | updateCurrentIndex(); 29 | } -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/carousel/carousel-index.ts: -------------------------------------------------------------------------------- 1 | import { getFirstMatchOrNull } from "../../../../lib/first-regex-match-or-null"; 2 | 3 | function getGrandParent(element: HTMLElement) { 4 | const parent = element.parentElement; 5 | if (!parent) return null; 6 | const grandParent = parent.parentElement; 7 | if (!grandParent) return null; 8 | return grandParent; 9 | } 10 | 11 | export type IndexAndChild = { 12 | index: number, 13 | child: HTMLElement 14 | } 15 | 16 | function getCarouselIndexByItemWidthAndStyle(itemWidth: number, item: HTMLElement): number | null { 17 | // `list.children` does not contain all of the carousels images. 18 | // only 3 images are loaded at any given time and others are loaded on demand. so we cannot use `i` as the items index! 19 | // but, the absolute translation of the list items indicates its actual index. 20 | const translationX = getFirstMatchOrNull( 21 | /(?<=translateX\()[\d\.]*(?=px)/.exec( 22 | item.style.transform 23 | ) 24 | ); 25 | if (!translationX) return null; 26 | return Math.round(parseFloat(translationX) / itemWidth); 27 | } 28 | 29 | export function getCurrentCarouselIndexAndChildByList(list: HTMLUListElement): IndexAndChild | null { 30 | 31 | const positionReferenceElement = getGrandParent(list); 32 | if (!positionReferenceElement) return null; 33 | const visibleX = positionReferenceElement.getBoundingClientRect().x; 34 | 35 | // the actual first item at index 0 is some kind of marker with width 1 36 | const startIndex = 1; 37 | 38 | const firstItem = list.children[startIndex]; 39 | const listItemWidth = parseFloat( 40 | getComputedStyle(firstItem).getPropertyValue("width") 41 | ); 42 | 43 | for (let i = startIndex; i < list.children.length; i++) { 44 | const listItem = list.children[i] as HTMLElement; 45 | const curItemX = listItem.getBoundingClientRect().x; 46 | // the currently visible item should be almost exactly at `visibleX` 47 | // to give some leeway, we check for proximity by half of the items width 48 | if (Math.abs(visibleX - curItemX) > listItemWidth / 2) continue; 49 | 50 | const index = getCarouselIndexByItemWidthAndStyle( 51 | listItemWidth, listItem 52 | ); 53 | if (index === null) continue; 54 | 55 | return { 56 | index, 57 | child: listItem as HTMLElement 58 | } 59 | } 60 | return null; 61 | } 62 | 63 | export function getCurrentCarouselIndexWithListAndChild(postEl: HTMLElement) { 64 | const list = postEl.querySelector("ul"); 65 | if (!list) return null; 66 | const indexAndChild = getCurrentCarouselIndexAndChildByList(list); 67 | if (!indexAndChild) return null; 68 | return { list, ...indexAndChild } 69 | } -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/carousel/carousel-item.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentCarouselIndexWithListAndChild } from "./carousel-index"; 2 | 3 | export function getCurrentCarouselElement(postEl: HTMLElement) { 4 | return getCurrentCarouselIndexWithListAndChild(postEl)?.child; 5 | } -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/carousel/carousel-media.ts: -------------------------------------------------------------------------------- 1 | import { queryMediaElement } from "../media-and-src/query-media-element"; 2 | import { getMediaSrc } from "../media-and-src/src-from-img-or-video"; 3 | import { getCurrentCarouselIndexWithListAndChild } from "./carousel-index"; 4 | 5 | export function getCarouselMediaByPostElement(postElement: HTMLElement) { 6 | const indexAndList = getCurrentCarouselIndexWithListAndChild(postElement); 7 | if (!indexAndList){ 8 | console.warn("could not find the current index of carousel"); 9 | return null; 10 | } 11 | 12 | const { index, child } = indexAndList; 13 | const listItem = child; 14 | 15 | const mediaEl = queryMediaElement(listItem as HTMLElement); 16 | if (!mediaEl){ 17 | console.warn("could not find any media element in carousel at index " + index, listItem); 18 | return null; 19 | } 20 | 21 | return getMediaSrc(mediaEl); 22 | }; -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/carousel/collection-details.ts: -------------------------------------------------------------------------------- 1 | import { Either, left, right } from "fp-ts/es6/Either"; 2 | import { getCurrentCarouselIndexWithListAndChild } from "./carousel-index"; 3 | import { queryMediaElement } from "../media-and-src/query-media-element"; 4 | 5 | 6 | export type CollectionDetails = { 7 | type: "collection", 8 | currentIndex: number, 9 | mediaElement: HTMLVideoElement | HTMLImageElement 10 | } 11 | 12 | export function getCollectionDetails(postElement: HTMLElement): Either { 13 | const indexAndList = getCurrentCarouselIndexWithListAndChild(postElement); 14 | if (!indexAndList) { 15 | return left({ 16 | message: "could not find index and list of carousel", 17 | post: postElement 18 | }) 19 | } 20 | const { index, child } = indexAndList; 21 | const mediaElement = queryMediaElement(child); 22 | if (!mediaElement) { 23 | return left({ 24 | message: "could not find a media element in carousel child", 25 | carouselChild: child 26 | }) 27 | } 28 | 29 | return right({ 30 | type: "collection", 31 | currentIndex: index, 32 | mediaElement: mediaElement 33 | }) 34 | } -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/carousel/indicator-dots.ts: -------------------------------------------------------------------------------- 1 | import { Either, isLeft, left, right } from "fp-ts/es6/Either"; 2 | import { isNone, none, Option, some } from "fp-ts/es6/Option"; 3 | import { VideoOrImgInfo } from "../../from-fetch-response/types"; 4 | 5 | export function findMediaEntryByIndicatorDots(mediaArray: VideoOrImgInfo[], postElement: HTMLElement) { 6 | const indicatorDotIndexEith = findIndicatorDotIndex(postElement); 7 | if (isLeft(indicatorDotIndexEith)) return indicatorDotIndexEith; 8 | return right( 9 | mediaArray[indicatorDotIndexEith.right] 10 | ); 11 | } 12 | 13 | export function findIndicatorDotIndex(postElement: HTMLElement): Either { 14 | const indicatorDotContainerOpt = findIndicatorDotsContainer(postElement); 15 | if (isNone(indicatorDotContainerOpt)) { 16 | return left("attempted to find the indicator dots of a collection, but no luck. instagram might have changed the DOM and the current method of detection doesn't work anymore"); 17 | } 18 | const indicatorDotContainer = indicatorDotContainerOpt.value; 19 | const indicatorDots = indicatorDotContainer.children; 20 | // how to tell if an indicator is active? currently, i see that all indicators have a common class while the active indicator has an extra class. so maybe just look whose className is the longest. 21 | let activeIndicatorDotIndex = 0; 22 | for (let i = 1; i < indicatorDots.length; i++) { 23 | const indicatorDot = indicatorDots[i]; 24 | if (indicatorDot.className.length <= indicatorDots[activeIndicatorDotIndex].className.length) { 25 | continue; 26 | } 27 | activeIndicatorDotIndex = i; 28 | } 29 | return right(activeIndicatorDotIndex); 30 | } 31 | 32 | // classNames cannot be trusted when trying to find elements in the DOM. 33 | // in this case, how would you find the container with multiple horizontally aligned dots? 34 | // this solution simply looks for any element of width > 100 and height between 4 and 10. 35 | // i cannot think of a better detection method than this right now. 36 | function findIndicatorDotsContainer(parent: Element): Option { 37 | const minWidth = 100; 38 | const maxHeight = 10; 39 | const minHeight = 4; 40 | 41 | if (!("clientWidth" in parent)) return none; 42 | const { clientWidth, clientHeight } = parent; 43 | if (clientWidth > minWidth && clientHeight < maxHeight && clientHeight > minHeight) { 44 | return some(parent); 45 | } 46 | if (!("children" in parent)) return none; 47 | for (const child of parent.children) { 48 | const subResult = findIndicatorDotsContainer(child); 49 | if (isNone(subResult)) continue; 50 | return subResult; 51 | } 52 | return none; 53 | } -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/general-post-info/post-href.ts: -------------------------------------------------------------------------------- 1 | 2 | export function getHrefOfPost(postElement: HTMLElement): string | null { 3 | const linkElements = postElement.querySelectorAll('a[href*="/p/"') as NodeListOf; 4 | if (linkElements.length === 0) return null; 5 | const href = linkElements[linkElements.length - 1].href; 6 | const index1 = href.indexOf("/p/"); 7 | if (index1 < 0) return null; 8 | const startIndex = index1 + 3; 9 | const endIndex = href.indexOf("/", startIndex); 10 | if (endIndex < 0) return null; 11 | return href.substring(0, endIndex + 1); 12 | }; -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/general-post-info/post-type.ts: -------------------------------------------------------------------------------- 1 | import { Option, none, some } from "fp-ts/es6/Option"; 2 | import { queryMediaElement } from "../media-and-src/query-media-element"; 3 | 4 | type PostType = "collection" | "video" | "image"; 5 | 6 | function postIsCarousel(postElement: HTMLElement, mediaElement: HTMLElement): boolean { 7 | let ancestorElement = mediaElement; 8 | for (let i = 0; i < 1000; i++){ 9 | if (ancestorElement.matches("li")) return true; 10 | if (ancestorElement === postElement) return false; 11 | const nextAncestorElement = ancestorElement.parentElement; 12 | if (!nextAncestorElement) return false; 13 | ancestorElement = nextAncestorElement; 14 | } 15 | return false; 16 | } 17 | 18 | export function findTypeOfPost(postElement: HTMLElement): Option { 19 | const mediaElement = queryMediaElement(postElement); 20 | if (!mediaElement) { 21 | // console.warn("no media-element found"); 22 | // console.log(postElement); 23 | return none; 24 | } 25 | 26 | if (postIsCarousel(postElement, mediaElement)){ 27 | return some("collection"); 28 | } 29 | 30 | return some( 31 | mediaElement.tagName === "VIDEO" ? "video" : "image" 32 | ); 33 | }; -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/general-post-info/post-username.ts: -------------------------------------------------------------------------------- 1 | import { Either, left, right } from "fp-ts/es6/Either"; 2 | 3 | export function findUsernameInPost(postElement: HTMLElement): Either { 4 | // it seems that the avatar of the poster and the commenters all have 5 | // an alt attribute like 'mike's profile picture'. 6 | // moreover, the first such avatar in the DOM appears to be the authors. 7 | // we can obtain this alt and the first word should be the authors username. 8 | const profilePictureImg = postElement.querySelector('img[alt*="profile picture"]'); 9 | if (!profilePictureImg){ 10 | return left(["could not find the authors username in this post", postElement]); 11 | } 12 | 13 | const alt = profilePictureImg.getAttribute("alt") as string; 14 | const username = alt.replace("'s profile picture", ""); 15 | 16 | return right(username); 17 | }; -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/media-and-src/media-extraction.ts: -------------------------------------------------------------------------------- 1 | import { isLeft } from "fp-ts/es6/Either"; 2 | import { getCarouselMediaByPostElement } from "../carousel/carousel-media"; 3 | import { findTypeOfPost } from "../general-post-info/post-type"; 4 | import { findUsernameInPost } from "../general-post-info/post-username"; 5 | import { queryMediaElement } from "./query-media-element"; 6 | import { getMediaSrc } from "./src-from-img-or-video"; 7 | import { isNone } from "fp-ts/es6/Option"; 8 | 9 | 10 | export function getMediaSrcByPostElement(postElement: HTMLElement){ 11 | const mediaElement = queryMediaElement(postElement); 12 | if (!mediaElement){ 13 | console.log("could not find any media element in post", postElement); 14 | return null; 15 | } 16 | return getMediaSrc(mediaElement); 17 | } 18 | 19 | 20 | export function getMediaSrcByHtml(postElement: HTMLElement){ 21 | const usernameEith = findUsernameInPost(postElement); 22 | if (isLeft(usernameEith)){ 23 | console.warn(usernameEith.left); 24 | return null; 25 | } 26 | const username = usernameEith.right; 27 | 28 | const postTypeOpt = findTypeOfPost(postElement); 29 | if (isNone(postTypeOpt)){ 30 | console.warn("could not find type of post"); 31 | return null; 32 | } 33 | const postType = postTypeOpt.value; 34 | 35 | const srcData = postType === "collection" ? getCarouselMediaByPostElement(postElement) : getMediaSrcByPostElement(postElement); 36 | 37 | if (!srcData){ 38 | console.warn("could not find media-src of post"); 39 | return null; 40 | } 41 | 42 | return { username, ...srcData }; 43 | }; -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/media-and-src/query-media-and-get-src.ts: -------------------------------------------------------------------------------- 1 | import { queryMediaElement } from "./query-media-element"; 2 | import { getMediaSrc } from "./src-from-img-or-video"; 3 | 4 | export function queryMediaAndGetSrc(postElement: HTMLElement) { 5 | const mediaElement = queryMediaElement(postElement); 6 | if (!mediaElement) { 7 | console.log("could not find any media element in post", postElement); 8 | return null; 9 | } 10 | return getMediaSrc(mediaElement); 11 | } -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/media-and-src/query-media-element.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from "fp-ts/es6/function"; 2 | 3 | 4 | function queryLargestImage(parent: HTMLElement){ 5 | return pipe( 6 | parent.querySelectorAll("img"), 7 | Array.from, 8 | (array: HTMLImageElement[]) => array.find( img => img.naturalWidth > 400 ) 9 | ) 10 | }; 11 | 12 | export function queryMediaElement(parent: HTMLElement){ 13 | return parent.querySelector("video") ?? queryLargestImage(parent); 14 | }; -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/media-and-src/src-from-img-or-video.ts: -------------------------------------------------------------------------------- 1 | import { VideoOrImageElement } from "../../media-types"; 2 | import { getHighestQualityFromSrcset } from "./srcset-util"; 3 | 4 | 5 | type SrcData = { 6 | type: "video" | "image", 7 | src: string 8 | } 9 | 10 | function getImageSrc(img: HTMLImageElement): string { 11 | if (img.srcset.length === 0) return img.src; 12 | return getHighestQualityFromSrcset(img.srcset); 13 | } 14 | 15 | export function getMediaSrc(mediaElement: VideoOrImageElement): SrcData { 16 | const type = mediaElement.tagName === "VIDEO" ? "video" : "image"; 17 | const src = type === "image" ? getImageSrc(mediaElement as HTMLImageElement) : mediaElement.src; 18 | return { type, src }; 19 | }; -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/media-and-src/srcset-util.ts: -------------------------------------------------------------------------------- 1 | 2 | type QualityAndSource = { 3 | quality: number, 4 | src: string 5 | } 6 | 7 | function parseSingleQualityAndSrc(entry: string): QualityAndSource { 8 | const splitted = entry.split(" "); 9 | return { 10 | src: splitted[0], 11 | quality: parseInt(splitted[1]) 12 | } 13 | } 14 | 15 | function parseAllQualityAndSrc(srcset: string): QualityAndSource[] { 16 | return srcset.split(",").map(parseSingleQualityAndSrc); 17 | } 18 | 19 | export function getHighestQualityFromSrcset(srcset: string): string { 20 | const qualityAndSources = parseAllQualityAndSrc(srcset); 21 | let maxQualIndex = 0; 22 | for (let i = 1; i < qualityAndSources.length; i++) { 23 | const curQual = qualityAndSources[i].quality; 24 | if (curQual > qualityAndSources[maxQualIndex].quality) { 25 | maxQualIndex = i; 26 | } 27 | } 28 | return qualityAndSources[maxQualIndex].src; 29 | }; -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/media-id.ts: -------------------------------------------------------------------------------- 1 | import { left, right } from "fp-ts/es6/Either"; 2 | 3 | // this is not working anymore, since instagram removed the media id completely from the dom 4 | export function findMediaIdOnPostPage(){ 5 | const metaElement = document.querySelector("meta[content*='instagram://media?id=']"); 6 | if (!metaElement){ 7 | return left("could not find media-id on this page. are you sure this is a page with a single post?") 8 | } 9 | const content = metaElement.getAttribute("content") as string; 10 | const mediaID = content.replace("instagram://media?id=", ""); 11 | return right(mediaID); 12 | } -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/own-username.ts: -------------------------------------------------------------------------------- 1 | import { Option, none, some } from "fp-ts/es6/Option"; 2 | 3 | export function getOwnUsername(): Option { 4 | const match = /(?<="username":")[^"]*/.exec(document.body.innerHTML); 5 | if (!match) return none; 6 | return some(match[0]); 7 | }; -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/shortcode-web-info/find-deep-property.ts: -------------------------------------------------------------------------------- 1 | import { Option, isNone, none, some } from "fp-ts/es6/Option"; 2 | 3 | export function findDeepPropertyByKey(key: string, root: any): Option { 4 | if (typeof (root) !== "object") return none; 5 | 6 | // typeof(null) is "object", so we need to watch out for that 7 | if (root === null) return none; 8 | 9 | if (Array.isArray(root)) { 10 | for (const item of root) { 11 | const subResult = findDeepPropertyByKey(key, item); 12 | if (isNone(subResult)) continue; 13 | return subResult; 14 | } 15 | } 16 | 17 | for (const [subKey, value] of Object.entries(root)) { 18 | if (subKey !== key) { 19 | const subResult = findDeepPropertyByKey(key, value); 20 | if (isNone(subResult)) continue; 21 | return subResult; 22 | } 23 | 24 | return some(value); 25 | } 26 | 27 | return none; 28 | } -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/shortcode-web-info/parse-script-json.ts: -------------------------------------------------------------------------------- 1 | import { Either, left, right } from "fp-ts/es6/Either"; 2 | 3 | export function tryParseScriptContents(script: HTMLScriptElement): Either { 4 | try { 5 | return right( 6 | JSON.parse(script.innerText) 7 | ); 8 | } 9 | catch (e) { 10 | return left(e); 11 | } 12 | } -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/shortcode-web-info/shortcode-from-url.ts: -------------------------------------------------------------------------------- 1 | import { none, some } from "fp-ts/es6/Option"; 2 | 3 | function getShortCodeByCurrentUrl() { 4 | const pathname = location.pathname; 5 | if (!pathname.startsWith("/p/")) return none; 6 | const pathnameTail = pathname.substring(3); 7 | return some( 8 | pathnameTail.substring( 9 | 0, pathnameTail.indexOf("/") 10 | ) 11 | ); 12 | } -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/shortcode-web-info/shortcode_media_script.ts: -------------------------------------------------------------------------------- 1 | import { findFirst } from "fp-ts/es6/Array"; 2 | import { Either, isLeft, left, right } from "fp-ts/es6/Either"; 3 | import { isNone } from "fp-ts/es6/Option"; 4 | import { pipe } from "fp-ts/es6/function"; 5 | import { DataItem } from "../../from-fetch-response/response-data-type"; 6 | import { tryParseScriptContents } from "./parse-script-json"; 7 | import { findDeepPropertyByKey } from "./find-deep-property"; 8 | 9 | export function tryFindWebInfoInPageScripts(): Either { 10 | const key = "xdt_api__v1__media__shortcode__web_info"; 11 | const scriptWithKeyOpt = pipe( 12 | document.body.querySelectorAll("script"), 13 | (scripts) => Array.from(scripts), 14 | findFirst( 15 | (script) => script.innerText.includes(key) 16 | ) 17 | ); 18 | 19 | if (isNone(scriptWithKeyOpt)){ 20 | return left(`could not find any script on this page that contains the string "${key}"`); 21 | } 22 | 23 | const scriptWithKey = scriptWithKeyOpt.value; 24 | const scriptObjEith = tryParseScriptContents(scriptWithKey); 25 | if (isLeft(scriptObjEith)) return scriptObjEith; 26 | const scriptObj = scriptObjEith.right; 27 | 28 | const webInfoOpt = findDeepPropertyByKey(key, scriptObj); 29 | if (isNone(webInfoOpt)){ 30 | return left([ 31 | `could not find any property with they key "${key}", weirdly enough`, 32 | scriptObj 33 | ]) 34 | } 35 | 36 | const webInfo = webInfoOpt.value; 37 | const items = webInfo.items; 38 | if (!items || !Array.isArray(items) || items.length === 0){ 39 | return left([ 40 | `the 'items' property on the webInfo object does not exist or is not an array with at least one item`, 41 | scriptObj 42 | ]) 43 | } 44 | 45 | return right(items[0]); 46 | } -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/social-media-posting/carousel-video-index.ts: -------------------------------------------------------------------------------- 1 | import { observeCarouselIndex } from "../../../carousel-index-observer"; 2 | import { queryMediaElement } from "../media-and-src/query-media-element"; 3 | import { waitForElementExistence } from "../../../../lib/await-element"; 4 | import { Lazy } from "fp-ts/es6/function"; 5 | 6 | // the object `SocialMediaPosting` contains videos and images of a carousel, 7 | // but they are in seperate arrays. 8 | // how do we get the correct video at a given carousel index? 9 | // it appears that the order of the videos is the same as their order in the carousel. 10 | 11 | // example: 12 | // carousel: [ image1, image2, video1, image3, video2, video3, image4, video4 ] 13 | // SocialMediaPosting.video: [ video1, video2 , video3, video4 ] 14 | // SocialMediaPosting.image: [ image1, image2 , image3, image4 ] 15 | 16 | // so if we are at index 4 in the carousel which is item `video2`, 17 | // how do we know that it is the second video? 18 | // the only idea i have so far is to keep track of how many videos we have 19 | // scrolled past in the carousel. 20 | // this module provides an observer for the video index. 21 | // be cautious! it may not be very robust! 22 | // always doublecheck your downloads! 23 | 24 | 25 | export function makeVideoIndexObserver(postElement: HTMLElement): Lazy { 26 | 27 | let videoIndex = 0; 28 | 29 | (async function(){ 30 | const carouselElement = await waitForElementExistence(100, 5, postElement, "ul"); 31 | 32 | let videoIndexInitialized = false; 33 | let isCurrentlyVideo = false; 34 | let previousIndex = 0; 35 | 36 | observeCarouselIndex( 37 | carouselElement, 38 | ({ child, index }) => { 39 | const mediaElement = queryMediaElement(child); 40 | if (!mediaElement) return; 41 | 42 | const isVideoElement = mediaElement.matches("video"); 43 | 44 | if (!videoIndexInitialized) { 45 | isCurrentlyVideo = isVideoElement; 46 | videoIndex = isCurrentlyVideo ? 0 : -1; 47 | videoIndexInitialized = true; 48 | } 49 | else { 50 | if (index > previousIndex) { 51 | const isVideoNext = isVideoElement; 52 | videoIndex += isVideoNext ? 1 : 0; 53 | } 54 | else if (index < previousIndex) { 55 | const wasVideoPrevious = isCurrentlyVideo; 56 | videoIndex += wasVideoPrevious ? -1 : 0; 57 | } 58 | isCurrentlyVideo = isVideoElement; 59 | } 60 | previousIndex = index; 61 | } 62 | ); 63 | })(); 64 | 65 | return () => videoIndex; 66 | } -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/social-media-posting/find-in-dom.ts: -------------------------------------------------------------------------------- 1 | import { Option, none, some } from "fp-ts/es6/Option"; 2 | import { SocialMediaPosting } from "./types"; 3 | 4 | // my previous methods of obtaining video urls is now broken, 5 | // but Instagram has kindly provided us with the exact 6 | // data we need in the DOM. 7 | // i believe this works only on post pages and not on the mainfeed, 8 | // but i haven't checked yet. 9 | 10 | export function findSocialMediaPostingInDom(): Option { 11 | const script = document.querySelector('script[type="application/ld+json"]'); 12 | if (!script) return none; 13 | 14 | try { 15 | const scriptParsed = JSON.parse(script.innerHTML); 16 | if (typeof(scriptParsed) !== "object") return none; 17 | 18 | // i've seen instances where the parsed result is an array 19 | // instead of a single object. 20 | // let's pack the object in an array so that we won't have 21 | // fragmented logic 22 | const resultArray = ( 23 | Array.isArray(scriptParsed) ? scriptParsed : [scriptParsed] 24 | ); 25 | 26 | const postingItem = resultArray.find( 27 | (item) => { 28 | if (typeof(item) !== "object") return false; 29 | return item["@type"] === "SocialMediaPosting"; 30 | } 31 | ); 32 | if (!postingItem) return none; 33 | 34 | // TODO: validation of type 35 | return some(postingItem as SocialMediaPosting); 36 | } 37 | catch(e){ 38 | return none; 39 | } 40 | } -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/social-media-posting/media-provider.ts: -------------------------------------------------------------------------------- 1 | import { isLeft, left, right } from "fp-ts/es6/Either"; 2 | import { Option, isNone, none } from "fp-ts/es6/Option"; 3 | import { getCurrentPageType, isSinglePostType } from "../../../insta-navigation-observer"; 4 | import { MediaFetchFn } from "../../../media-fetch-fn"; 5 | import { PostType } from "../../from-fetch-response/types"; 6 | import { findTypeOfPost } from "../general-post-info/post-type"; 7 | import { findUsernameInPost } from "../general-post-info/post-username"; 8 | import { tryGetImageSrc } from "../try-get-image-src"; 9 | import { makeVideoIndexObserver } from "./carousel-video-index"; 10 | import { findSocialMediaPostingInDom } from "./find-in-dom"; 11 | import { SocialMediaPosting } from "./types"; 12 | 13 | // make a function that lazily extracts media from this post. 14 | // if it's an image, it will query the image source. 15 | // if it's a video, it will use `SocialMediaPosting` taken 16 | // from a certain script element in the DOM. 17 | // for carousels, it is also necessary to keep track of the 18 | // current video index, because videos and images are stored 19 | // in separate arrays. 20 | 21 | // granted, it is not very pretty! 22 | // i have basically copy pasted this code from another place 23 | // when i was still doing fetches. 24 | // for fetches, it was necessary to cache as many values as possible. 25 | // i should definitely rewrite this function and split it 26 | // into multiple cases (image, single video, carousel, ...). 27 | 28 | export function makeSocialMediaPostingExtractor(postElement: HTMLElement): MediaFetchFn { 29 | 30 | // videoIndex is not needed if this is an image, 31 | // but since everything is done lazily, we need 32 | // to keep track of the video index if it's a carousel, 33 | // to have it ready when the download button is pressed. 34 | const getVideoIndex = makeVideoIndexObserver(postElement); 35 | 36 | // cached values 37 | let socialMediaPosting: Option = none; 38 | let currentPostType: Option = none; 39 | 40 | return async () => { 41 | 42 | // ------------------------ 43 | 44 | if (isNone(currentPostType)) { 45 | currentPostType = findTypeOfPost(postElement); 46 | } 47 | 48 | // check again if postType is some 49 | if (isNone(currentPostType)) { 50 | return left([ 51 | "could not find type of post", postElement 52 | ]) 53 | } 54 | 55 | const postType = currentPostType.value; 56 | 57 | // ------------------------ 58 | 59 | 60 | 61 | // if this current post or carousel item is an image, 62 | // then we can quickly find its source 63 | const imageSrcData = tryGetImageSrc(postType, postElement); 64 | if (imageSrcData) { 65 | const usernameEith = findUsernameInPost(postElement); 66 | if (isLeft(usernameEith)) { 67 | return usernameEith; 68 | } 69 | 70 | return right({ 71 | username: usernameEith.right, 72 | ...imageSrcData 73 | }) 74 | } 75 | 76 | // case: single video or carousel video on mainfeed 77 | if (!isSinglePostType(getCurrentPageType())) { 78 | return left( 79 | "please open the page of this post in a new tab. downloading videos directly from the mainfeed is currently not supported." 80 | ) 81 | } 82 | 83 | 84 | // case: single- or carousel-video on post-page 85 | 86 | if (isNone(socialMediaPosting)) { 87 | socialMediaPosting = findSocialMediaPostingInDom(); 88 | } 89 | 90 | if (isNone(socialMediaPosting)) { 91 | return left("couldn't find any social-media posting in the DOM"); 92 | } 93 | 94 | const mediaPost = socialMediaPosting.value; 95 | const videoItems = mediaPost.video; 96 | const { author } = mediaPost; 97 | const username = author.identifier?.value ?? author.alternateName; 98 | 99 | const videoIndex = getVideoIndex(); 100 | if (videoIndex < 0 || videoIndex >= videoItems.length) { 101 | return left("video index is out of bounds. i'm as surprised as you are."); 102 | } 103 | 104 | return right({ 105 | type: "video", 106 | username, 107 | src: videoItems[videoIndex].contentUrl 108 | }); 109 | } 110 | }; -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/social-media-posting/types.ts: -------------------------------------------------------------------------------- 1 | export type SocialMediaPosting = { 2 | "@type": "SocialMediaPosting", 3 | articleBody: string, 4 | author: Person, 5 | identifier: { 6 | propertyID: "Post Shortcode", 7 | value: string 8 | }, 9 | image: ImageObject[], 10 | video: VideoObject[] 11 | } 12 | 13 | type Person = { 14 | "@type": "Person", 15 | 16 | name: string, // more like display name 17 | 18 | alternateName: string, // this seems to be the real username, but also could be an alias 19 | 20 | identifier: { 21 | propertyID: "Username", 22 | value: string // i suppose use this for foldernames 23 | } | null, 24 | 25 | image: string, // profile pic maybe 26 | 27 | url: string 28 | } 29 | 30 | export type ImageObject = { 31 | width: string, 32 | height: string, 33 | representativeOfPage: boolean, 34 | url: string 35 | } 36 | 37 | export type VideoObject = { 38 | width: string, 39 | height: string, 40 | thumbnailUrl: string, 41 | contentUrl: string 42 | } -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/stories/main-element.ts: -------------------------------------------------------------------------------- 1 | 2 | export function findStoryElement() { 3 | const parent = document.body; 4 | const firstCanvas = parent.querySelector("button canvas"); 5 | if (!firstCanvas) return; 6 | 7 | let currentChild = firstCanvas.parentElement; 8 | if (currentChild === null) return; 9 | 10 | let currentElement = currentChild; 11 | 12 | for (let a = 0; a < 10000; a++) { 13 | const currentParent = currentElement.parentElement; 14 | if (!currentParent) { 15 | return; 16 | } 17 | if (currentParent.offsetHeight - currentElement.offsetHeight < 0) { 18 | return currentParent; 19 | } 20 | currentElement = currentParent; 21 | } 22 | console.warn("either something went wrong or the dom is very large"); 23 | } -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/stories/source.ts: -------------------------------------------------------------------------------- 1 | import { getHighestQualityFromSrcset } from "../media-and-src/srcset-util"; 2 | 3 | type SourceEl = HTMLSourceElement; 4 | 5 | function getVideoSrc(storyElement: HTMLElement) { 6 | const video = storyElement.querySelector("video"); 7 | if (video === null) return; 8 | 9 | // there seems to be several sources that have different file sizes 10 | // when downloaded. i want to get the biggest file, cuz that's probably 11 | // where the quality is. after some research i found out the sources use different 12 | // video codecs for compression. 13 | // there are 3 "tiers": baseline, main and high and that's what 14 | // these 3 numbers mean (42, 4D, 64). to get the highest possible level, 15 | // we just sort the sources by that tier 16 | const sources = Array.from(video.querySelectorAll("source")); 17 | 18 | const codecOrder = ["42", "4D", "64"]; 19 | const getOrderOfCodec = (el: SourceEl) => { 20 | return codecOrder.findIndex(val => el.type.includes(val)); 21 | }; 22 | const sourceSortFunc = (a: SourceEl, b: SourceEl) => getOrderOfCodec(a) - getOrderOfCodec(b); 23 | sources.sort(sourceSortFunc); 24 | 25 | return sources[sources.length - 1].src; 26 | } 27 | 28 | export function getSrcOfStory(storyElement: HTMLElement) { 29 | 30 | // don't change the order here! 31 | // the order is important, first query a video THEN if no video was found, query an image. 32 | // a video-story will also have an image for preview, so there is an image element in either case! 33 | 34 | const videoSrc = getVideoSrc(storyElement); 35 | if (videoSrc) return videoSrc; 36 | 37 | const img = storyElement.querySelector('img[srcset]') as HTMLImageElement; 38 | if (img !== null) { 39 | return getHighestQualityFromSrcset(img.srcset); 40 | } 41 | 42 | return null; 43 | }; -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/stories/story-id.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from "fp-ts/es6/function"; 2 | import { fromNullable } from "fp-ts/es6/Option"; 3 | import { getFirstMatchOrNull } from "../../../../lib/first-regex-match-or-null"; 4 | 5 | export function findStoryIdInUrl(){ 6 | return pipe( 7 | location.pathname, 8 | pathName => /(?<=\/stories\/.*\/)\d*/.exec(pathName), 9 | getFirstMatchOrNull, 10 | fromNullable 11 | ) 12 | } -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/stories/story-index.ts: -------------------------------------------------------------------------------- 1 | import { findIndex, lookup as lookupArrayItem, map as mapArray } from "fp-ts/es6/Array"; 2 | import { Either, isLeft, left, right } from "fp-ts/es6/Either"; 3 | import { pipe } from "fp-ts/es6/function"; 4 | import { fold, fromNullable, isNone, map as mapOption } from "fp-ts/es6/Option"; 5 | 6 | 7 | function findStorySection(): Either { 8 | return pipe( 9 | // query all sections on the page 10 | document.querySelectorAll("section") as NodeListOf, 11 | // convert from NodeList to array 12 | (arg) => Array.from(arg), 13 | // calculate the size of each section and bundle it with the section 14 | mapArray( 15 | (section) => ({ 16 | section, 17 | size: section.offsetWidth * section.offsetHeight, 18 | }) 19 | ), 20 | // sort the sections by their size in descending order 21 | (items) => { 22 | items = items.slice(); 23 | items.sort( 24 | (a, b) => b.size - a.size 25 | ); 26 | return items; 27 | }, 28 | // take the section of largest size 29 | lookupArrayItem(0), 30 | mapOption( 31 | ({ section }) => section, 32 | ), 33 | // if the number of sections was zero, then we return an error 34 | fold( 35 | () => left( 36 | "expected to find the story-element in the largest section element on this page, but this query returned null. are you sure you are currently watching a story? if yes, instagram might have changed things up in the DOM. i'm deeply sorry and ashamed." 37 | ), 38 | (section) => right(section) 39 | ) 40 | ); 41 | } 42 | 43 | function findStoryProgressBarsContainer(sectionEl: HTMLElement): Either { 44 | 45 | // find the download button 46 | const downloadButton = sectionEl.querySelector(".download-button") as HTMLElement; 47 | 48 | // find the first ancestor wider than 200 pixels. 49 | // if the dom is as expected, the first sibling 50 | // of that element is the progress-bars container. 51 | const wideAncestorEith = (function(){ 52 | let current: HTMLElement = downloadButton; 53 | for (let i = 0; i < 20; i++){ 54 | if (current.offsetWidth > 200) return right(current); 55 | const next = fromNullable(current?.parentElement); 56 | if (isNone(next)){ 57 | break; 58 | } 59 | current = next.value; 60 | } 61 | return left("starting from the download button, we attempted to find an ancestor with a width greater than 200 pixels, but had no success."); 62 | })(); 63 | 64 | if (isLeft(wideAncestorEith)) return wideAncestorEith; 65 | 66 | return pipe( 67 | wideAncestorEith.right, 68 | (el) => el.parentElement?.firstElementChild as HTMLElement | null | undefined, 69 | fromNullable, 70 | fold( 71 | () => left( 72 | "found an ancestor of the download button with the expected width, but not the expected DOM structure" 73 | ), 74 | right 75 | ) 76 | ); 77 | } 78 | 79 | function isProgressBarUnfinished(progressBar: HTMLElement): boolean { 80 | return progressBar.childElementCount > 0; 81 | } 82 | 83 | export function findCurrentStoryIndex(){ 84 | const storySection = findStorySection(); 85 | if (isLeft(storySection)) return storySection; 86 | const progressBarsContainer = findStoryProgressBarsContainer(storySection.right); 87 | if (isLeft(progressBarsContainer)) return progressBarsContainer; 88 | const storyIndex = pipe( 89 | progressBarsContainer.right.children, 90 | Array.from, 91 | findIndex(isProgressBarUnfinished) 92 | ); 93 | if (isNone(storyIndex)){ 94 | return left( 95 | `trying to find the current story-index by looking for the first progress-bar that has not finished. but it appears that every single progress-bar has completed.` 96 | ) 97 | } 98 | return right(storyIndex.value); 99 | } -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/stories/story-type.ts: -------------------------------------------------------------------------------- 1 | 2 | export function getCurrentStoryType(){ 3 | const { pathname } = location; 4 | if (pathname.startsWith("/stories/highlights/")){ 5 | return "highlight_reel"; 6 | } 7 | return "user_reel"; 8 | } -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/stories/username.ts: -------------------------------------------------------------------------------- 1 | import { findFirstMap } from 'fp-ts/es6/Array'; 2 | import { Either, left, fromNullable as fromNullableEith, right } from 'fp-ts/es6/Either'; 3 | import { flow, pipe } from 'fp-ts/es6/function'; 4 | import { fromNullable, isNone } from 'fp-ts/es6/Option'; 5 | import { getFirstMatchOrNull } from '../../../../lib/first-regex-match-or-null'; 6 | 7 | 8 | export function getUsernameOfStory() { 9 | const url = window.location.href; 10 | if (location.pathname.startsWith("/stories/highlights/")) { 11 | return findUsernameOnProfilePage(); 12 | } 13 | return getUsernameByStoryUrl(url); 14 | } 15 | 16 | function usernameEitherFromUrl(regex: RegExp, url: string): Either { 17 | return fromNullableEith 18 | (`could not extract username from url: ${url}`) 19 | (getFirstMatchOrNull(regex.exec(url))); 20 | } 21 | 22 | function findUsernameOnProfilePage() { 23 | const usernameRegex = /(?<=:\/\/www\.instagram\.com\/).*?(?=\/)/; 24 | const usernameMatchOpt = pipe( 25 | 'link[href*="://www.instagram.com/"]', 26 | query => document.querySelectorAll(query), 27 | Array.from, 28 | findFirstMap( 29 | flow( 30 | (linkEl) => linkEl.href, 31 | (href) => usernameRegex.exec(href), 32 | fromNullable 33 | ) 34 | ) 35 | ); 36 | 37 | if (isNone(usernameMatchOpt)){ 38 | return left("trying to find a username on the profile page, but there seems to be no link element where we could read off the url."); 39 | } 40 | const username = usernameMatchOpt.value[0]; 41 | return right(username); 42 | } 43 | 44 | function getUsernameByStoryUrl(storyUrl: string) { 45 | return usernameEitherFromUrl( 46 | /(?<=stories\/).*?(?=\/)/, storyUrl 47 | ); 48 | } -------------------------------------------------------------------------------- /app/src/data-extraction/directly-in-browser/try-get-image-src.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentCarouselIndexWithListAndChild } from "./carousel/carousel-index"; 2 | import { queryMediaAndGetSrc } from "./media-and-src/query-media-and-get-src"; 3 | import { queryMediaElement } from "./media-and-src/query-media-element"; 4 | import { getMediaSrc } from "./media-and-src/src-from-img-or-video"; 5 | import { PostType } from "../from-fetch-response/types"; 6 | 7 | export function tryGetImageSrc(postType: PostType, postElement: HTMLElement) { 8 | if (postType === "video") return null; 9 | if (postType === "image") return queryMediaAndGetSrc(postElement); 10 | 11 | const indexAndList = getCurrentCarouselIndexWithListAndChild(postElement); 12 | if (!indexAndList) { 13 | return null; 14 | } 15 | const mediaElement = queryMediaElement(indexAndList.child); 16 | if (!mediaElement) return null; 17 | if (!(mediaElement instanceof HTMLImageElement)) return null; 18 | return getMediaSrc(mediaElement); 19 | } -------------------------------------------------------------------------------- /app/src/data-extraction/from-fetch-response/cached-media-fetching.ts: -------------------------------------------------------------------------------- 1 | import { Either, isLeft, right } from "fp-ts/es6/Either"; 2 | import { getCurrentCarouselIndexWithListAndChild } from "../directly-in-browser/carousel/carousel-index"; 3 | import { findMediaEntryByIndicatorDots } from "../directly-in-browser/carousel/indicator-dots"; 4 | import { queryMediaAndGetSrc } from "../directly-in-browser/media-and-src/query-media-and-get-src"; 5 | import { queryMediaElement } from "../directly-in-browser/media-and-src/query-media-element"; 6 | import { getMediaSrc } from "../directly-in-browser/media-and-src/src-from-img-or-video"; 7 | import { getHrefOfPost } from "../directly-in-browser/general-post-info/post-href"; 8 | import { findTypeOfPost } from "../directly-in-browser/general-post-info/post-type"; 9 | import { findUsernameInPost } from "../directly-in-browser/general-post-info/post-username"; 10 | import { MediaInfo, PostType, SingleMediaInfo } from "./types"; 11 | import { toNullable } from "fp-ts/es6/Option"; 12 | 13 | 14 | function tryGetImageSrc(postType: PostType, postElement: HTMLElement){ 15 | if (postType === "video") return null; 16 | if (postType === "image") return queryMediaAndGetSrc(postElement); 17 | 18 | const indexAndList = getCurrentCarouselIndexWithListAndChild(postElement); 19 | if (!indexAndList) { 20 | console.warn("could not find the current index of carousel"); 21 | return null; 22 | } 23 | const mediaElement = queryMediaElement(indexAndList.child); 24 | if (!mediaElement) return null; 25 | if (!(mediaElement instanceof HTMLImageElement)) return null; 26 | return getMediaSrc(mediaElement); 27 | } 28 | 29 | 30 | type FetchFunc = (url: string) => Promise>; 31 | 32 | export const createMediaFetcherBySrcElementAndFetchFunc = (fetchFunc: FetchFunc) => (postElement: HTMLElement) => { 33 | let currentMediaInfo: (MediaInfo | null) = null; 34 | let currentPostType: (PostType | null) = null; 35 | 36 | // the following function may be called several times, for example from carousel elements. the fetch reponse for a carousel element contains all the sources for each carousel item. we don't have to fetch again if another item from the same carousel is downloaded next. 37 | 38 | return async (): Promise => { 39 | if (!currentPostType) { 40 | currentPostType = toNullable(findTypeOfPost(postElement)); 41 | if (!currentPostType){ 42 | console.warn("could not find type of post"); 43 | return; 44 | } 45 | } 46 | 47 | const imageSrcData = tryGetImageSrc(currentPostType, postElement); 48 | if (imageSrcData){ 49 | const usernameEith = findUsernameInPost(postElement); 50 | if (isLeft(usernameEith)){ 51 | console.warn(usernameEith); 52 | return; 53 | } 54 | return { 55 | username: usernameEith.right, 56 | ...imageSrcData 57 | } 58 | } 59 | 60 | if (!currentMediaInfo) { 61 | const postHref = getHrefOfPost(postElement); 62 | if (!postHref){ 63 | console.warn("could not find href of post"); 64 | return; 65 | } 66 | 67 | const fetchResult = await fetchFunc(postHref); 68 | if (isLeft(fetchResult)){ 69 | console.warn(fetchResult.left); 70 | return; 71 | } 72 | currentMediaInfo = fetchResult.right; 73 | } 74 | const { username, mediaArray } = currentMediaInfo; 75 | const videoOrImgInfo = currentPostType === "collection" ? findMediaEntryByIndicatorDots(mediaArray, postElement) : right(mediaArray[0]); 76 | if (isLeft(videoOrImgInfo)){ 77 | console.error(videoOrImgInfo.left); 78 | return; 79 | } 80 | return { username, ...videoOrImgInfo.right } 81 | } 82 | }; 83 | 84 | // export const createMediaFetcherBySrcElement = createMediaFetcherBySrcElementAndFetchFunc(fetchMediaInfo); 85 | -------------------------------------------------------------------------------- /app/src/data-extraction/from-fetch-response/fetch-media-data.ts: -------------------------------------------------------------------------------- 1 | import { sort } from 'fp-ts/es6/Array'; 2 | import { isLeft, left, right } from 'fp-ts/es6/Either'; 3 | import { flow } from 'fp-ts/es6/function'; 4 | import { Ord } from 'fp-ts/es6/number'; 5 | import { isSome, none, Option, some } from 'fp-ts/es6/Option'; 6 | import { contramap, reverse } from 'fp-ts/es6/Ord'; 7 | import { parseDashManifestAndExtractData } from './video-dash-manifest'; 8 | import { CarouselItem, MediaInfo, PostType, VersionItem, VideoInfo, VideoOrImgInfo, VideoOrImgItem } from './types'; 9 | import { DataItem } from './response-data-type'; 10 | 11 | 12 | const getImageArea = (item: VersionItem) => item.width * item.height; 13 | const versionAreaOrd = contramap(getImageArea)(reverse(Ord)); 14 | 15 | function getBestQualityVersion(versions: VersionItem[]): string { 16 | // todo: replace sort by `max` (or `min`) 17 | const versionsSorted = sort(versionAreaOrd)(versions); 18 | return versionsSorted[0].url; 19 | } 20 | 21 | 22 | 23 | function getVersions(item: VideoOrImgItem): VersionItem[] { 24 | if (item["video_versions"]) return item.video_versions; 25 | return item["image_versions2"]["candidates"]; 26 | } 27 | 28 | const getMediaSrcFromSingleItem = flow(getVersions, getBestQualityVersion); 29 | 30 | function extractVideoAndAudioFromDashManifest(item: VideoOrImgItem): Option { 31 | if (!item["video_dash_manifest"]) return none; 32 | const dashDataEither = parseDashManifestAndExtractData( 33 | item["video_dash_manifest"] 34 | ); 35 | if (isLeft(dashDataEither)) { 36 | console.error(dashDataEither.left); 37 | return none; 38 | } 39 | const dashData = dashDataEither.right; 40 | if (dashData.warnings.length > 0) { 41 | console.warn(dashData.warnings); 42 | } 43 | // if (isNone(dashData.data.audio)) return none; 44 | return some({ 45 | type: "video", 46 | src: dashData.data.video.url, 47 | previewSrc: item["image_versions2"]["candidates"][0]["url"] 48 | // videoSrc: dashData.data.video.url, 49 | // audioSrc: dashData.data.audio.value.url 50 | }) 51 | } 52 | 53 | export function getMediaInfoFromSingleItem(item: VideoOrImgItem): VideoOrImgInfo { 54 | if (item["video_versions"]) { 55 | const manifestExtraction = extractVideoAndAudioFromDashManifest(item); 56 | if (isSome(manifestExtraction)) { 57 | return manifestExtraction.value; 58 | } 59 | 60 | return { 61 | type: "video", 62 | src: getBestQualityVersion(item["video_versions"]), 63 | previewSrc: item["image_versions2"]["candidates"][0].url 64 | }; 65 | } 66 | 67 | const imgSrc = getBestQualityVersion(item["image_versions2"]["candidates"]); 68 | return { 69 | type: "image", 70 | src: imgSrc, 71 | previewSrc: imgSrc 72 | } 73 | } 74 | 75 | 76 | function getMediaInfoFromCarousel(carousel: CarouselItem): VideoOrImgInfo[] { 77 | return carousel["carousel_media"].map(getMediaInfoFromSingleItem) 78 | } 79 | 80 | 81 | export function getMediaInfoFromDataItem(item: DataItem): Either { 82 | const username: string = item.user.username; 83 | 84 | let mediaArray: VideoOrImgInfo[] = []; 85 | 86 | let postType: PostType = "video"; 87 | 88 | if (item["carousel_media"]) { 89 | postType = "collection"; 90 | mediaArray = getMediaInfoFromCarousel(item); 91 | } 92 | else if (item["video_versions"]) { 93 | postType = "video"; 94 | mediaArray.push( 95 | getMediaInfoFromSingleItem(item) 96 | ); 97 | } 98 | else { 99 | postType = "image"; 100 | mediaArray.push( 101 | getMediaInfoFromSingleItem(item) 102 | ); 103 | } 104 | 105 | if (mediaArray.length === 0) { 106 | return left('no media found'); 107 | } 108 | 109 | return right({ 110 | postType, mediaArray, username 111 | }); 112 | } 113 | 114 | export function getMediaInfoFromResponseObject(responseObject: object): MediaInfo { 115 | if (!responseObject.items) { 116 | console.log(responseObject); 117 | throw 'items not found in dataObject (see above log)'; 118 | } 119 | const items = responseObject.items; 120 | if (items.length === 0) { 121 | throw 'items are empty'; 122 | } 123 | const item = items[0]; 124 | const username: string = item.user.username; 125 | 126 | let mediaArray: VideoOrImgInfo[] = []; 127 | 128 | let postType: PostType = "video"; 129 | 130 | if (item["video_versions"]) { 131 | postType = "video"; 132 | mediaArray.push( 133 | getMediaInfoFromSingleItem(item) 134 | ); 135 | } 136 | 137 | if (item["image_versions2"]) { 138 | postType = "image"; 139 | mediaArray.push( 140 | getMediaInfoFromSingleItem(item) 141 | ); 142 | } 143 | 144 | if (item["carousel_media"]) { 145 | postType = "collection"; 146 | mediaArray = getMediaInfoFromCarousel(item); 147 | } 148 | 149 | if (mediaArray.length === 0) { 150 | throw 'no media found'; 151 | } 152 | 153 | return { 154 | postType, 155 | mediaArray, 156 | username 157 | } as MediaInfo; 158 | } 159 | 160 | const getMediaInfoFromResponseText = (responseText: string): MediaInfo => { 161 | const dataText = /(?<=window\.__additionalDataLoaded\(.*',).*(?=\);<)/.exec(responseText); 162 | if (!dataText) throw '__additionalDataLoaded not found on window'; 163 | if (!Array.isArray(dataText)) { 164 | console.log(dataText); 165 | throw 'dataText is not an array! (see above log what it actually is, i have no idea)'; 166 | } 167 | 168 | let dataObject = JSON.parse(dataText[0]); 169 | return getMediaInfoFromResponseObject(dataObject); 170 | }; 171 | 172 | export async function fetchMediaInfo(url: string): Promise { 173 | const fetchResult = await fetch(url); 174 | const responseText = await fetchResult.text(); 175 | const data = getMediaInfoFromResponseText(responseText); 176 | return data; 177 | }; -------------------------------------------------------------------------------- /app/src/data-extraction/from-fetch-response/find-carousel-item.ts: -------------------------------------------------------------------------------- 1 | import { getHighestQualityFromSrcset } from "../directly-in-browser/media-and-src/srcset-util"; 2 | import { getCurrentCarouselElement } from '../directly-in-browser/carousel/carousel-item'; 3 | import { ImgInfo, VideoInfo, VideoOrImgInfo } from "./types"; 4 | import { queryMediaElement } from "../directly-in-browser/media-and-src/query-media-element"; 5 | import { getFirstMatchOrNull } from "../../../lib/first-regex-match-or-null"; 6 | import { Either, left, right } from "fp-ts/es6/Either"; 7 | import { isNone, none, Option, some } from "fp-ts/es6/Option"; 8 | 9 | 10 | /** 11 | * when fetching media of a carousel-post, we are getting all of the carousel-items at once. but we actually want to get a specific item where the download button was pressed. 12 | * the fetched media is an array of items, so the straightforward thing would be to look at the index of the carousel element and then access the array at that index. but there is no guarantee that the array is ordered the same as the carousel items in browser. 13 | * therefore, i've resolved to more robust methods like comparing sources of preview images. 14 | */ 15 | 16 | 17 | function findMediaEntryByVideo(mediaArray: VideoOrImgInfo[], videoEl: HTMLVideoElement) { 18 | const poster = videoEl.poster; 19 | if (poster === "") { 20 | console.warn("cannot find the position for this collection-element!"); 21 | return null; 22 | } 23 | const trimmedPoster = getFirstMatchOrNull( 24 | /^https:\/\/.*\.jpg/.exec(poster) 25 | ); 26 | if (!trimmedPoster) return null; 27 | 28 | const mediaIndex = mediaArray.findIndex( 29 | val => val.previewSrc.includes(trimmedPoster) 30 | ); 31 | if (mediaIndex < 0) { 32 | console.warn("poster does not match any previews, therefore cannot find the index for this item"); 33 | console.log(poster, trimmedPoster, mediaArray); 34 | return null; 35 | } 36 | 37 | return mediaArray[mediaIndex] as VideoInfo; 38 | } 39 | 40 | export function findMediaEntryByImage(imgEl: HTMLImageElement): ImgInfo { 41 | let highQualiSrc = ""; 42 | const srcset = imgEl.srcset; 43 | if (srcset) { 44 | highQualiSrc = getHighestQualityFromSrcset(srcset); 45 | } 46 | else { 47 | highQualiSrc = imgEl.src; 48 | } 49 | return { 50 | type: "image", 51 | src: highQualiSrc, 52 | previewSrc: "", 53 | } 54 | } 55 | 56 | export function findMediaEntryByCarousel(mediaArray: VideoOrImgInfo[], postElement: HTMLElement) { 57 | const collectionElement = getCurrentCarouselElement(postElement); 58 | if (!collectionElement){ 59 | console.warn("couldn't find carousel element"); 60 | return null; 61 | } 62 | const mediaElement = queryMediaElement(collectionElement); 63 | if (!mediaElement){ 64 | console.warn("couldn't find image or video"); 65 | return null; 66 | } 67 | if (mediaElement.matches("video")) { 68 | return findMediaEntryByVideo(mediaArray, mediaElement as HTMLVideoElement); 69 | } 70 | if (mediaElement.matches("img")) { 71 | return findMediaEntryByImage(mediaElement as HTMLImageElement); 72 | } 73 | console.warn("mediaElement does not match video or img"); 74 | return null; 75 | } -------------------------------------------------------------------------------- /app/src/data-extraction/from-fetch-response/media-id.ts: -------------------------------------------------------------------------------- 1 | import { fromNullable } from "fp-ts/es6/Either"; 2 | import { getFirstMatchOrNull } from "../../../lib/first-regex-match-or-null"; 3 | 4 | export async function fetchMediaID(postUrl: string){ 5 | const response = await (await fetch(postUrl)).text(); 6 | return fromNullable 7 | ("couldn't find media-id in response") 8 | ( 9 | getFirstMatchOrNull( 10 | /(?<="instagram:\/\/media\?id=)\d*(?=")/.exec(response) 11 | ) 12 | ) 13 | } -------------------------------------------------------------------------------- /app/src/data-extraction/from-fetch-response/response-data-type.ts: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | this is an attempt to reconstruct the data-types from a few different fetch responses. 4 | 5 | how to make a fetch request: 6 | - pick some instagram post of your choice. important is that the url looks like "https://www.instagram.com/p/Cbcy9-boMZ8/" 7 | - go to **any** instagram page, open dev-tools and execute the following command in the console: fetch("https://www.instagram.com/p/Cbcy9-boMZ8/").then(response => response.text()).then(console.log) 8 | this request cannot be made from any webpage, only from the instagram domain 9 | - copy the result 10 | - the relevant data occurs after the string "__additionalDataLoaded('/p/Cbcy9-boMZ8/', " 11 | the second argument, an object, after '/p/Cbcy9-boMZ8/' is where all the interesting data resides and is denoted by `AdditionalData` in this module 12 | 13 | i've saved some responses to './dev/reference-data' for convenience. 14 | */ 15 | 16 | 17 | export type AdditionalData = { 18 | "num_results": number, 19 | "more_available": boolean, 20 | "auto_load_more_enabled": boolean, 21 | "items": [DataItem] 22 | }; 23 | 24 | 25 | 26 | type OriginalSize = { 27 | "original_width": number, 28 | "original_height": number, 29 | }; 30 | 31 | 32 | type VideoVersion = { 33 | "id": string, 34 | "type": number, // examples: 101, 102, 103 35 | "width": number, 36 | "height": number, 37 | "url": string 38 | }; 39 | 40 | type ImageVersion2Candidate = { 41 | "width": number, 42 | "height": number, 43 | "url": string 44 | }; 45 | 46 | type ImageVersions2 = { 47 | "candidates": ImageVersion2Candidate[] 48 | }; 49 | 50 | 51 | // data items ### 52 | 53 | export type DataItem = CarouselItem | VideoItem | ImageItem; 54 | 55 | 56 | type DataItemBase = { 57 | "id": string, 58 | "code": string, 59 | "is_unified_video": boolean, 60 | "device_timestamp": number, 61 | "taken_at": number, 62 | }; 63 | 64 | 65 | // those types are split up like that because videos and images in `CarouselItem` look almost exactly like those in `VideoItem` or `ImageItem` just without a couple of properties. 66 | 67 | type ImageItemBase = OriginalSize & { 68 | "media_type": 1, 69 | "image_versions2": ImageVersions2, 70 | // "product_type": "feed" 71 | }; 72 | type ImageItem = DataItemBase & ImageItemBase & { 73 | "product_type": "feed" 74 | }; 75 | 76 | 77 | 78 | type VideoItemWithoutDashManifest = { 79 | "media_type": 2, 80 | "video_duration": number, 81 | "video_versions": VideoVersion[] 82 | "image_versions2": { 83 | "candidates": ImageVersion2Candidate[], 84 | } 85 | }; 86 | type DashManifestAndMore = { 87 | "is_dash_eligible": number, // 0 for false, 1 for true? 88 | "video_dash_manifest": string, 89 | "video_codec": string, 90 | "number_of_qualities": number, 91 | }; 92 | type VideoItemBase = OriginalSize & VideoItemWithoutDashManifest & ({} | DashManifestAndMore); 93 | 94 | type VideoItem = DataItemBase & VideoItemBase & { 95 | "product_type": "feed" | "igtv" | "clips", 96 | "has_audio": boolean, 97 | "image_versions2": { 98 | "candidates": ImageVersion2Candidate[], 99 | "additional_candidates": { 100 | "igtv_first_frame": ImageVersion2Candidate, 101 | "first_frame": ImageVersion2Candidate 102 | } 103 | } 104 | }; 105 | 106 | 107 | 108 | type CarouselItem = DataItemBase & { 109 | "media_type": 8, 110 | // "product_type": "carousel_container" 111 | "carousel_media_count": number, 112 | "carousel_media": (VideoItemBase | ImageItemBase)[], 113 | }; 114 | -------------------------------------------------------------------------------- /app/src/data-extraction/from-fetch-response/types.ts: -------------------------------------------------------------------------------- 1 | 2 | export type VersionItem = { 3 | width: number, 4 | height: number, 5 | url: string 6 | }; 7 | 8 | export type VideoItem = { 9 | "carousel_media": null, 10 | "video_versions": VersionItem[], 11 | "image_versions2": { 12 | "candidates": VersionItem[] 13 | }, 14 | "video_dash_manifest": string | null 15 | }; 16 | export type ImageItem = { 17 | "carousel_media": null, 18 | "video_versions": null, 19 | "video_dash_manifest": string | null 20 | 21 | "image_versions2": { 22 | "candidates": VersionItem[] 23 | } 24 | }; 25 | export type VideoOrImgItem = VideoItem | ImageItem; 26 | 27 | export type CarouselItem = { 28 | "video_versions": null, 29 | "image_versions2": null, 30 | "carousel_media": VideoOrImgItem[] 31 | }; 32 | 33 | 34 | 35 | // type SplitVideoAudioInfo = { 36 | // videoSrc: string, 37 | // audioSrc: string 38 | // }; 39 | type VideoOrImgInfoBase = { 40 | src: string, 41 | previewSrc: string 42 | }; 43 | export type VideoInfo = VideoOrImgInfoBase & { type: "video" }; 44 | export type ImgInfo = VideoOrImgInfoBase & { type: "image" }; 45 | export type VideoOrImgInfo = VideoInfo | ImgInfo; 46 | 47 | 48 | export type PostType = "collection" | "video" | "image"; 49 | 50 | export type MediaInfo = { 51 | username: string, 52 | postType: PostType, 53 | mediaArray: VideoOrImgInfo[] 54 | } 55 | 56 | export type SingleMediaInfo = { 57 | username: string, 58 | src: string, 59 | type: "video" | "image" 60 | } -------------------------------------------------------------------------------- /app/src/data-extraction/hybrid/cached-media-fetching.ts: -------------------------------------------------------------------------------- 1 | import { isLeft, left, right } from "fp-ts/es6/Either"; 2 | import { getCurrentPageType, isSinglePostType } from "../../insta-navigation-observer"; 3 | import { findTypeOfPost } from "../directly-in-browser/general-post-info/post-type"; 4 | import { findUsernameInPost } from "../directly-in-browser/general-post-info/post-username"; 5 | import { findMediaEntryByIndicatorDots } from "../directly-in-browser/carousel/indicator-dots"; 6 | import { MediaInfo, PostType } from "../from-fetch-response/types"; 7 | import { tryGetImageSrc } from "../directly-in-browser/try-get-image-src"; 8 | import { Option, isNone, none, some, toNullable } from "fp-ts/es6/Option"; 9 | import { MediaFetchFn } from "../../media-fetch-fn"; 10 | import { tryFindWebInfoInPageScripts } from "../directly-in-browser/shortcode-web-info/shortcode_media_script"; 11 | import { getMediaInfoFromDataItem } from "../from-fetch-response/fetch-media-data"; 12 | 13 | export const makeWebInfoMediaExtractor = (postElement: HTMLElement): MediaFetchFn => { 14 | 15 | let currentMediaInfo: Option = none; 16 | let currentPostType: Option = none; 17 | 18 | // the following function may be called several times, for example from carousel elements. the fetch reponse for a carousel element contains all the sources for each carousel item. we don't have to fetch again if another item from the same carousel is downloaded next. 19 | 20 | return async function(){ 21 | 22 | // ------------------------ 23 | 24 | if (isNone(currentPostType)) { 25 | currentPostType = findTypeOfPost(postElement); 26 | } 27 | 28 | // check again if postType is some 29 | if (isNone(currentPostType)) { 30 | return left([ 31 | "could not find type of post", postElement 32 | ]) 33 | } 34 | 35 | const postType = currentPostType.value; 36 | 37 | // ------------------------ 38 | 39 | 40 | // if this current post or carousel item is an image, then we can quickly find its source 41 | const imageSrcData = tryGetImageSrc(postType, postElement); 42 | if (imageSrcData) { 43 | const usernameEith = findUsernameInPost(postElement); 44 | if (isLeft(usernameEith)) { 45 | return usernameEith; 46 | } 47 | 48 | return right({ 49 | username: usernameEith.right, 50 | ...imageSrcData 51 | }) 52 | } 53 | 54 | 55 | // case: single video or carousel video on mainfeed 56 | if (!isSinglePostType(getCurrentPageType())) { 57 | return left( 58 | "please open the page of this post in a new tab. downloading videos directly from the mainfeed is currently not supported." 59 | ) 60 | } 61 | 62 | 63 | // the current media is either a single video or a carousel video item 64 | if (isNone(currentMediaInfo)) { 65 | const webInfoEith = tryFindWebInfoInPageScripts(); 66 | if (isLeft(webInfoEith)) { 67 | return webInfoEith; 68 | } 69 | const webInfo = webInfoEith.right; 70 | const mediaInfoEith = getMediaInfoFromDataItem(webInfo); 71 | if (isLeft(mediaInfoEith)){ 72 | return mediaInfoEith; 73 | } 74 | 75 | currentMediaInfo = some(mediaInfoEith.right); 76 | } 77 | 78 | if (isNone(currentMediaInfo)){ 79 | return left("this fail case should never occur"); 80 | } 81 | 82 | const mediaInfo = currentMediaInfo.value; 83 | const { mediaArray } = mediaInfo; 84 | 85 | const videoOrImgInfo = ( 86 | postType === "collection" 87 | ? findMediaEntryByIndicatorDots(mediaArray, postElement) 88 | : right(mediaArray[0]) 89 | ); 90 | 91 | if (isLeft(videoOrImgInfo)) { 92 | return videoOrImgInfo.left; 93 | } 94 | 95 | return right({ 96 | username: mediaInfo.username, 97 | ...videoOrImgInfo.right 98 | }); 99 | } 100 | }; -------------------------------------------------------------------------------- /app/src/data-extraction/hybrid/media-id-of-post.ts: -------------------------------------------------------------------------------- 1 | import { isRight, left } from "fp-ts/es6/Either"; 2 | import { findMediaIdOnPostPage } from "../directly-in-browser/media-id"; 3 | import { getHrefOfPost } from "../directly-in-browser/general-post-info/post-href"; 4 | import { fetchMediaID } from "../from-fetch-response/media-id"; 5 | 6 | // this method doesn't work anymore! 7 | // instagram removed the media ID from the DOM entirely! 8 | 9 | export async function queryOrFetchMediaId(postElement: HTMLElement) { 10 | // first try to find the media-ID in the DOM 11 | const queriedMediaID = findMediaIdOnPostPage(); 12 | if (isRight(queriedMediaID)) return queriedMediaID; 13 | 14 | // media-ID was not found in the DOM. 15 | // this could have several reasons, maybe the post is an overlay or we're on the mainfeed. 16 | // now try to get a response of the post page and extract it from there. 17 | const postHref = getHrefOfPost(postElement); 18 | if (!postHref) { 19 | return left("could not find url of post"); 20 | } 21 | return await fetchMediaID(postHref); 22 | } -------------------------------------------------------------------------------- /app/src/data-extraction/hybrid/media-of-post.ts: -------------------------------------------------------------------------------- 1 | import { isLeft, right } from "fp-ts/es6/Either"; 2 | import { getMediaInfoFromResponseObject } from "../from-fetch-response/fetch-media-data"; 3 | import { fetchMediaInfoWithCurrentHeaders } from "../instagram-api/media-info"; 4 | 5 | export async function fetchMediaOnCurrentPageAndExtract() { 6 | const mediaInfoJsonEither = await fetchMediaInfoWithCurrentHeaders(); 7 | if (isLeft(mediaInfoJsonEither)) { 8 | throw mediaInfoJsonEither; 9 | } 10 | const extractedInfo = getMediaInfoFromResponseObject( 11 | mediaInfoJsonEither.right 12 | ); 13 | return right(extractedInfo); 14 | } -------------------------------------------------------------------------------- /app/src/data-extraction/instagram-api/media-info.ts: -------------------------------------------------------------------------------- 1 | import { isLeft, isRight, left, right } from "fp-ts/es6/Either"; 2 | import { stringifyRequestBody } from "../../../lib/stringify-request-body"; 3 | import { getRequestHeadersAndBody, RequestHeadersAndBody } from "./request-header-collection/foreground-collector"; 4 | import { getCurrentMediaID } from "./request-header-collection/media-id-collector"; 5 | import { makeApiUrl } from "./url-maker"; 6 | 7 | 8 | // # graphl # 9 | 10 | async function fetchMediaInfoByGraphql(headersAndBody: RequestHeadersAndBody) { 11 | const { headers, body } = headersAndBody; 12 | const bodyStringified = stringifyRequestBody(body); 13 | try { 14 | const response = await fetch( 15 | "https://www.instagram.com/api/graphql", 16 | { 17 | method: 'POST', 18 | credentials: "include", 19 | headers, 20 | body: bodyStringified 21 | } 22 | ); 23 | const responseJson = await response.json() as object; 24 | return right(responseJson); 25 | } 26 | catch(e){ 27 | return left(e); 28 | } 29 | } 30 | 31 | // # api # 32 | 33 | function makeMediaFetchUrl(mediaId: string): string { 34 | return makeApiUrl(`media/${mediaId}/info/`); 35 | } 36 | 37 | export async function fetchMediaInfoByApi(headersAndBody: RequestHeadersAndBody) { 38 | const mediaIdEith = getCurrentMediaID(); 39 | if (isLeft(mediaIdEith)) return mediaIdEith; 40 | const mediaID = mediaIdEith.right; 41 | const { headers } = headersAndBody; 42 | 43 | const response = await fetch( 44 | makeMediaFetchUrl(mediaID), 45 | { credentials: "include", headers } 46 | ); 47 | return right(await response.json() as object); 48 | } 49 | 50 | 51 | export async function fetchMediaInfoWithCurrentHeaders(){ 52 | const headersAndBodyEith = getRequestHeadersAndBody(); 53 | if (isLeft(headersAndBodyEith)) return headersAndBodyEith; 54 | const headersAndBody = headersAndBodyEith.right; 55 | 56 | let mediaInfoByApi = await fetchMediaInfoByApi(headersAndBody); 57 | if (isRight(mediaInfoByApi)) return mediaInfoByApi; 58 | console.warn(mediaInfoByApi.left); 59 | 60 | // api call with media id did not work, try graphql next ... 61 | const mediaInfoByGraphql = await fetchMediaInfoByGraphql(headersAndBody); 62 | if (isLeft(mediaInfoByGraphql)) return mediaInfoByGraphql; 63 | const mediaInfoKey = "xdt_api__v1__media__shortcode__web_info"; 64 | const mediaInfoUnpacked = (mediaInfoByGraphql.right as any).data?.[mediaInfoKey]; 65 | if (mediaInfoUnpacked === undefined){ 66 | return left({ 67 | message: `'response.data.${mediaInfoKey}' is not defined`, 68 | response: mediaInfoByGraphql.right 69 | }); 70 | } 71 | 72 | return right(mediaInfoUnpacked); 73 | } -------------------------------------------------------------------------------- /app/src/data-extraction/instagram-api/request-header-collection/foreground-collector.ts: -------------------------------------------------------------------------------- 1 | import { Either, isLeft, left, right } from "fp-ts/es6/Either"; 2 | import { Option, isNone, none, some } from "fp-ts/es6/Option"; 3 | import { runtime } from "webextension-polyfill"; 4 | 5 | 6 | 7 | // ## headers ## 8 | 9 | type RequestHeader = Record; 10 | 11 | 12 | let currentHeaders: RequestHeader | null = ({ 13 | "X-IG-App-ID": "936619743392459" 14 | }); 15 | 16 | const errorMessage = 'trying to fetch media info from instagram API, but there was no previous request that we could imitate. please check if `web-request-listener.ts` and `foreground-collector.ts` are working properly.'; 17 | 18 | export function getCurrentHeadersOrThrow() { 19 | if (!currentHeaders) throw errorMessage; 20 | return currentHeaders; 21 | } 22 | 23 | export function getCurrentHeadersAsEither() { 24 | if (!currentHeaders) return left(errorMessage); 25 | return right(currentHeaders); 26 | } 27 | 28 | 29 | 30 | // ## body ## 31 | 32 | type InterceptedRequestBody = Record; 33 | 34 | let currentBody: Option = none; 35 | 36 | let receivedRequestHeaders = false; 37 | 38 | runtime.onMessage.addListener( 39 | function (message) { 40 | // set the requestBody only once! 41 | // this is important, because there can be multiple requests to graphql 42 | // that have nothing to do with fetching media information. 43 | // (i observed requests that merely seem to verify credentials or something) 44 | // it looks like only the first graphql request is exactly what we need. 45 | if ("requestBody" in message && isNone(currentBody)) { 46 | currentBody = some(message.requestBody); 47 | } 48 | 49 | // set `currentHeaders` only once for the same reason as above. 50 | // i'm not all to familiar with requestHeaders and don't know 51 | // if using the same headers from two different calls is okay or not. 52 | // but to be on the safe side, use only the headers of the first graphql request. 53 | // (i believe requestHeaders and requestBodies always come in pairs, but i'm not entirely sure) 54 | if ("requestHeaders" in message && !receivedRequestHeaders){ 55 | currentHeaders = message.requestHeaders; 56 | receivedRequestHeaders = true; 57 | } 58 | } 59 | ); 60 | 61 | 62 | 63 | // ## headers and body combined ## 64 | 65 | export type RequestHeadersAndBody = { 66 | headers: RequestHeader, 67 | body: InterceptedRequestBody 68 | } 69 | 70 | export function getRequestHeadersAndBody(): Either { 71 | const headersEith = getCurrentHeadersAsEither(); 72 | if (isLeft(headersEith)) return headersEith; 73 | if (isNone(currentBody)){ 74 | return left("we have not received any requestBody from the background script so far. please check if everything is working in order."); 75 | } 76 | 77 | return right({ 78 | headers: headersEith.right, 79 | body: currentBody.value 80 | }) 81 | } -------------------------------------------------------------------------------- /app/src/data-extraction/instagram-api/request-header-collection/media-id-collector.ts: -------------------------------------------------------------------------------- 1 | import { Either, left, right } from "fp-ts/es6/Either"; 2 | import { runtime } from "webextension-polyfill"; 3 | 4 | let currentMediaID: Either = left('trying to find the media ID, but there was no previous request. please check if `web-request-listener.ts` and `foreground-collector.ts` are working properly.'); 5 | 6 | export function getCurrentMediaID() { 7 | return currentMediaID; 8 | } 9 | 10 | runtime.onMessage.addListener( 11 | function (request) { 12 | if (!("mediaID" in request)) return; 13 | currentMediaID = right(request.mediaID); 14 | } 15 | ); -------------------------------------------------------------------------------- /app/src/data-extraction/instagram-api/request-header-collection/web-request-listener.ts: -------------------------------------------------------------------------------- 1 | import { tabs, webNavigation, webRequest, WebRequest } from "webextension-polyfill"; 2 | 3 | 4 | console.log("listening for instagram API calls ..."); 5 | 6 | // ## headers ## 7 | 8 | 9 | function objectifyRequestHeaders(headers: WebRequest.HttpHeadersItemType[]) { 10 | const obj: Record = {}; 11 | for (const { name, value } of headers) { 12 | if (value === undefined) continue; 13 | obj[name] = value; 14 | } 15 | return obj; 16 | } 17 | 18 | 19 | // ## listener ## 20 | 21 | 22 | // # api # 23 | 24 | function detectMediaID(details: WebRequest.OnSendHeadersDetailsType) { 25 | const { tabId, url } = details; 26 | const mediaIdMatch = /(?<=instagram\.com\/api\/v1\/media\/)\d*(?=\/info)/.exec(url); 27 | if (!mediaIdMatch) return; 28 | 29 | const mediaID = mediaIdMatch[0]; 30 | tabs.sendMessage( 31 | tabId, { mediaID } 32 | ); 33 | } 34 | 35 | webRequest.onSendHeaders.addListener( 36 | detectMediaID, 37 | { 38 | urls: [ 39 | "*://i.instagram.com/api/*", 40 | "*://www.instagram.com/api/*" 41 | ] 42 | }, 43 | ["requestHeaders"] 44 | ); 45 | 46 | 47 | // # graphql # 48 | 49 | const polarisPostRootQuery = "PolarisPostRootQuery"; 50 | 51 | const graphqlUrls: string[] = [ 52 | // "*://www.instagram.com/graphql/query", 53 | "*://www.instagram.com/api/graphql" 54 | ]; 55 | 56 | webRequest.onSendHeaders.addListener( 57 | (details) => { 58 | const { requestHeaders } = details; 59 | if (!requestHeaders) return; 60 | console.log("graphql request headers", details); 61 | 62 | const X_FB_Friendly_Name_entry = requestHeaders.find( 63 | ({ name }) => name === "X-FB-Friendly-Name" 64 | ); 65 | if (!X_FB_Friendly_Name_entry) return; 66 | if (X_FB_Friendly_Name_entry.value !== polarisPostRootQuery) return; 67 | 68 | tabs.sendMessage( 69 | details.tabId, 70 | { requestHeaders: objectifyRequestHeaders(requestHeaders) } 71 | ); 72 | }, 73 | { urls: graphqlUrls }, 74 | [ "requestHeaders" ] 75 | ); 76 | 77 | webRequest.onBeforeRequest.addListener( 78 | (details) => { 79 | console.log("graphql request body", details); 80 | 81 | const formData = details.requestBody?.formData; 82 | if (!formData) return; 83 | 84 | const { fb_api_req_friendly_name } = formData; 85 | if (!fb_api_req_friendly_name) return; 86 | if (fb_api_req_friendly_name[0] !== polarisPostRootQuery) return; 87 | 88 | tabs.sendMessage( 89 | details.tabId, { requestBody: formData } 90 | ); 91 | }, 92 | { urls: graphqlUrls }, 93 | [ "requestBody" ] 94 | ); 95 | 96 | 97 | 98 | // this listener is needed to wake up the background script whenever we navigate to instagram. 99 | // see https://stackoverflow.com/a/71431963 100 | webNavigation.onHistoryStateUpdated.addListener( 101 | (details) => { 102 | console.log('waking up'); 103 | } 104 | ); -------------------------------------------------------------------------------- /app/src/data-extraction/instagram-api/stories/main-feed.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/app/src/data-extraction/instagram-api/stories/main-feed.ts -------------------------------------------------------------------------------- /app/src/data-extraction/instagram-api/stories/user-stories-highlights.ts: -------------------------------------------------------------------------------- 1 | import { isLeft, left, right } from "fp-ts/es6/Either"; 2 | import { pipe } from "fp-ts/es6/function"; 3 | import { isNone } from "fp-ts/es6/Option"; 4 | import { makeRegexFn } from "../../../../lib/first-regex-match-or-null"; 5 | import { getCurrentHeadersAsEither } from "../request-header-collection/foreground-collector"; 6 | import { makeApiUrl } from "../url-maker"; 7 | 8 | function makeStoriesFetchUrl(reelID: string): string { 9 | return makeApiUrl(`feed/reels_media/?reel_ids=highlight%3A${reelID}`); 10 | } 11 | 12 | export const getCurrentStoryHighlightID = () => pipe( 13 | location.pathname, 14 | makeRegexFn(/(?<=\/stories\/highlights\/)\d*/) 15 | ) 16 | 17 | export async function fetchCurrentStoryHighlight(headers: Record, storyHighlightID: string) { 18 | const response = await fetch( 19 | makeStoriesFetchUrl(storyHighlightID), 20 | { credentials: "include", headers } 21 | ); 22 | return right(await response.json() as object); 23 | } 24 | 25 | export async function fetchCurrentUserStoryHighlightWithCurrentHeaders() { 26 | const headersEith = getCurrentHeadersAsEither(); 27 | if (isLeft(headersEith)) return headersEith; 28 | const storyHighlightIdOpt = getCurrentStoryHighlightID(); 29 | if (isNone(storyHighlightIdOpt)){ 30 | return left("could not find the ID of the highlights-story"); 31 | } 32 | return fetchCurrentStoryHighlight( 33 | headersEith.right, storyHighlightIdOpt.value 34 | ); 35 | } -------------------------------------------------------------------------------- /app/src/data-extraction/instagram-api/stories/user-stories-main.ts: -------------------------------------------------------------------------------- 1 | import { isLeft, right } from "fp-ts/es6/Either"; 2 | import { getCurrentHeadersOrThrow } from "../request-header-collection/foreground-collector"; 3 | import { makeApiUrl } from "../url-maker"; 4 | 5 | function makeStoriesFetchUrl(userID: string): string { 6 | return makeApiUrl(`feed/reels_media/?reel_ids=${userID}`); 7 | } 8 | 9 | export async function fetchUserStoryData(headers: Record, mediaID: string) { 10 | const response = await fetch( 11 | makeStoriesFetchUrl(mediaID), 12 | { credentials: "include", headers } 13 | ); 14 | return right(await response.json() as object); 15 | } 16 | 17 | export async function fetchUserStoryDataWithCurrentHeaders(userID: string) { 18 | const headers = getCurrentHeadersOrThrow(); 19 | return fetchUserStoryData(headers, userID); 20 | } -------------------------------------------------------------------------------- /app/src/data-extraction/instagram-api/url-maker.ts: -------------------------------------------------------------------------------- 1 | 2 | const urlHead = "https://www.instagram.com/api/v1/"; 3 | 4 | export function makeApiUrl(tail: string){ 5 | return `${urlHead}${tail}`; 6 | } -------------------------------------------------------------------------------- /app/src/data-extraction/instagram-api/user-info.ts: -------------------------------------------------------------------------------- 1 | import { Either, right } from "fp-ts/es6/Either"; 2 | import { getCurrentHeadersOrThrow } from "./request-header-collection/foreground-collector"; 3 | import { makeApiUrl } from "./url-maker"; 4 | 5 | function makeUserFetchUrl(userName: string): string { 6 | // this url takes about half a second to fetch. this api-call is actually for retrieving the users feed, but i found that setting the number of feed-items to 1 is reasonably fast (~600 milliseconds). why not set it to 0? i've tried setting it to 0, but then it fetches like 17 feed items. so the lowest number that works is 1. 7 | // i hope to find a faster way of fetching user-data in the future. 8 | return makeApiUrl(`feed/user/${userName}/username/?count=1`); 9 | } 10 | 11 | export type UserInfo = { 12 | full_name: string, 13 | is_private: boolean, 14 | pk: number, 15 | username: string, 16 | profile_pic_url: string 17 | } 18 | 19 | export async function fetchUserInfo(headers: Record, userName: string): Promise> { 20 | const response = await fetch( 21 | makeUserFetchUrl(userName), 22 | { credentials: "include", headers } 23 | ); 24 | const responseObj = await response.json() as object; 25 | console.log("fetched user-info", responseObj); 26 | const userObj = responseObj.user as UserInfo; 27 | return right(userObj); 28 | } 29 | 30 | export async function fetchUserInfoWithCurrentHeaders(userName: string) { 31 | const headers = getCurrentHeadersOrThrow(); 32 | return fetchUserInfo(headers, userName); 33 | } -------------------------------------------------------------------------------- /app/src/data-extraction/is-currently-post-story-or-preview.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentPageType } from "../insta-navigation-observer"; 2 | 3 | export type InstaElementType = "preview" | "post" | "story"; 4 | 5 | export const getElementTypesOnCurrentPage = (): InstaElementType[] => { 6 | const curPageType = getCurrentPageType(); 7 | if (curPageType === "personFeed") { 8 | return ["preview"]; 9 | } 10 | else if (curPageType === "stories") { 11 | return ["story"]; 12 | } 13 | return ["post"]; 14 | }; -------------------------------------------------------------------------------- /app/src/data-extraction/media-types.ts: -------------------------------------------------------------------------------- 1 | 2 | export type VideoOrImageElement = HTMLVideoElement | HTMLImageElement 3 | 4 | export type SingleMediaInfo = { 5 | username: string, 6 | src: string, 7 | type: "video" | "image" 8 | } -------------------------------------------------------------------------------- /app/src/disk-writing/chrome-download-background.ts: -------------------------------------------------------------------------------- 1 | 2 | import { downloads, Runtime, runtime } from "webextension-polyfill"; 3 | import { DownloadRequest, DownloadProgressResponse, DownloadSuccessResponse, DownloadErrorResponse, DownloadStateRequest, DownloadIdResponse } from "./chrome-download-types"; 4 | 5 | 6 | // type InterruptReason = "FILE_FAILED" | "FILE_ACCESS_DENIED" | "FILE_NO_SPACE" | "FILE_NAME_TOO_LONG" | "FILE_TOO_LARGE" | "FILE_VIRUS_INFECTED" | "FILE_TRANSIENT_ERROR" | "FILE_BLOCKED" | "FILE_SECURITY_CHECK_FAILED" | "FILE_TOO_SHORT" | "FILE_HASH_MISMATCH" | "FILE_SAME_AS_SOURCE" | "NETWORK_FAILED" | "NETWORK_TIMEOUT" | "NETWORK_DISCONNECTED" | "NETWORK_SERVER_DOWN" | "NETWORK_INVALID_REQUEST" | "SERVER_FAILED" | "SERVER_NO_RANGE" | "SERVER_BAD_CONTENT" | "SERVER_UNAUTHORIZED" | "SERVER_CERT_PROBLEM" | "SERVER_FORBIDDEN" | "SERVER_UNREACHABLE" | "SERVER_CONTENT_LENGTH_MISMATCH" | "SERVER_CROSS_ORIGIN_REDIRECT" | "USER_CANCELED" | "USER_SHUTDOWN" | "CRASH"; 7 | 8 | 9 | async function handleDownloadRequest(port: Runtime.Port, msg: DownloadRequest){ 10 | try { 11 | const id = await downloads.download({ 12 | url: msg.url, 13 | filename: msg.filePath, 14 | conflictAction: "prompt" 15 | }); 16 | port.postMessage({ 17 | type: "download-id", id 18 | } as DownloadIdResponse); 19 | } 20 | catch (e) { 21 | port.postMessage({ 22 | type: "error", 23 | error: e 24 | } as DownloadErrorResponse); 25 | } 26 | } 27 | 28 | async function handleStateRequest(port: Runtime.Port, msg: DownloadStateRequest){ 29 | const items = await downloads.search({ id: msg.id }); 30 | if (items.length === 0) { 31 | port.postMessage({ 32 | type: "error", 33 | error: "download started but file not found. this may be a problem with the browser." 34 | } as DownloadErrorResponse); 35 | return; 36 | } 37 | if (items.length > 1) { 38 | console.warn("more than one file for this download found. this should not happen"); 39 | } 40 | const item = items[0]; 41 | const state = item.state; 42 | if (state === "interrupted") { 43 | port.postMessage({ 44 | type: "error", 45 | error: item.error 46 | } as DownloadErrorResponse); 47 | return; 48 | } 49 | if (state === "complete") { 50 | port.postMessage({ 51 | type: "success" 52 | } as DownloadSuccessResponse); 53 | return; 54 | } 55 | if (state === "in_progress") { 56 | port.postMessage({ 57 | type: "progress", 58 | progress: { 59 | bytesReceived: item.bytesReceived, 60 | totalBytes: item.totalBytes, 61 | progress: (item.bytesReceived / item.totalBytes) 62 | } 63 | } as DownloadProgressResponse); 64 | return; 65 | } 66 | } 67 | 68 | runtime.onConnect.addListener( 69 | (port) => { 70 | if (port.name !== "chrome-downloader") return; 71 | 72 | port.onDisconnect.addListener( 73 | () => console.log("port disconnected") 74 | ); 75 | port.onMessage.addListener( 76 | async (msg: DownloadRequest | DownloadStateRequest, sender) => { 77 | console.log(msg); 78 | if (msg.type === "request-download"){ 79 | handleDownloadRequest(port, msg); 80 | return; 81 | } 82 | 83 | if (msg.type === "request-state"){ 84 | handleStateRequest(port, msg); 85 | } 86 | } 87 | ); 88 | } 89 | ); -------------------------------------------------------------------------------- /app/src/disk-writing/chrome-download-types.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | export interface DownloadRequest { 4 | type: "request-download", 5 | filePath: string, 6 | url: string 7 | }; 8 | export interface DownloadStateRequest { 9 | type: "request-state", 10 | id: number 11 | }; 12 | 13 | export interface DownloadIdResponse { 14 | type: "download-id", 15 | id: number 16 | }; 17 | 18 | export interface DownloadErrorResponse { 19 | type: "error", 20 | error: string 21 | }; 22 | export interface DownloadSuccessResponse { 23 | type: "success" 24 | }; 25 | export type DownloadProgressCallback = (progressData: DownloadProgress) => void; 26 | export interface DownloadProgress { 27 | progress: number, 28 | bytesReceived: number, 29 | totalBytes: number 30 | }; 31 | export interface DownloadProgressResponse { 32 | type: "progress", 33 | progress: DownloadProgress 34 | }; 35 | export type DownloadResponse = DownloadErrorResponse | 36 | DownloadSuccessResponse | 37 | DownloadProgressResponse | 38 | DownloadIdResponse; -------------------------------------------------------------------------------- /app/src/disk-writing/chrome-download.ts: -------------------------------------------------------------------------------- 1 | import { runtime } from "webextension-polyfill"; 2 | import { DownloadRequest, DownloadResponse, DownloadErrorResponse, DownloadStateRequest } from "./chrome-download-types"; 3 | 4 | type ProgressCallback = (progress: number) => void; 5 | 6 | export const download = ( 7 | data: { filePath: string, url: string }, 8 | progressCallback: ProgressCallback): Promise => { 9 | 10 | return new Promise((resolve, reject) => { 11 | const port = runtime.connect({ 12 | name: "chrome-downloader" 13 | }); 14 | 15 | let downloadId = null; 16 | const requestState = () => { 17 | port.postMessage({ 18 | type: "request-state", 19 | id: downloadId 20 | } as DownloadStateRequest); 21 | }; 22 | port.onMessage.addListener((answer: DownloadResponse) => { 23 | if (answer.type === "download-id"){ 24 | downloadId = answer.id; 25 | requestState(); 26 | return; 27 | } 28 | if (answer.type === "error"){ 29 | reject(answer.error); 30 | port.disconnect(); 31 | return; 32 | } 33 | if (answer.type === "success") { 34 | resolve(); 35 | port.disconnect(); 36 | return; 37 | } 38 | if (answer.type === "progress"){ 39 | progressCallback(answer.progress.progress); 40 | requestState(); 41 | return; 42 | } 43 | }); 44 | port.postMessage({ 45 | type: "request-download", 46 | filePath: data.filePath, 47 | url: data.url 48 | } as DownloadRequest); 49 | }); 50 | }; -------------------------------------------------------------------------------- /app/src/disk-writing/disk-download-background.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Christian on 15.08.2017. 3 | */ 4 | 5 | import { runtime } from "webextension-polyfill"; 6 | 7 | interface NativeProgressResponse { 8 | type: "progress", 9 | data: { 10 | progress: number 11 | } 12 | }; 13 | interface NativeHostErrorResponse { 14 | type: "error", 15 | data: "wrong action-key" | 16 | "wrong data-key" | 17 | "wrong link-key" | 18 | "wrong folderPath-key" | 19 | "wrong fileName-key" | 20 | string; 21 | }; 22 | interface NativeDownloadSuccessResponse { 23 | type: "success", 24 | data: string 25 | }; 26 | type NativeHostResponse = NativeHostErrorResponse | 27 | NativeDownloadSuccessResponse | 28 | NativeProgressResponse; 29 | 30 | interface ResponseToForeground { 31 | origin: string, 32 | data: string | NativeHostResponse 33 | }; 34 | 35 | 36 | const nativeHostName = "insta_loader_host"; 37 | 38 | const connectToNativeHost = (request, sender, responseFunc) => { 39 | console.log("connecting to native host..."); 40 | console.log("request", request); 41 | const hostName = nativeHostName; 42 | try { 43 | const port = runtime.connectNative(hostName); 44 | port.onMessage.addListener(message => { 45 | console.log("received native message", message); 46 | responseFunc({ 47 | origin: "native host response", 48 | data: message 49 | } as ResponseToForeground); 50 | }); 51 | port.onDisconnect.addListener(() => { 52 | const errorMessage = runtime.lastError?.message; 53 | console.log("disconnected from native host", errorMessage); 54 | responseFunc({ 55 | origin: "native host disconnect", 56 | data: errorMessage 57 | } as ResponseToForeground); 58 | }); 59 | 60 | const requestString = JSON.stringify(request); 61 | port.postMessage(requestString); 62 | } 63 | catch(e){ 64 | console.log(e); 65 | return; 66 | } 67 | }; 68 | 69 | runtime.onConnect.addListener(function (port) { 70 | if (port.name !== "disk-downloader") return; 71 | port.onMessage.addListener( 72 | (msg, sender) => { 73 | connectToNativeHost( 74 | msg, sender, 75 | responseFn => { 76 | port.postMessage(responseFn); 77 | } 78 | ); 79 | } 80 | ); 81 | }); -------------------------------------------------------------------------------- /app/src/disk-writing/disk-download.ts: -------------------------------------------------------------------------------- 1 | import { runtime } from "webextension-polyfill"; 2 | 3 | interface DiskDownloadArgs { 4 | link: string, 5 | folderPath: string, 6 | fileName: string 7 | }; 8 | 9 | type DownloadProgressCallback = (progress: number) => void; 10 | 11 | export const download = ( 12 | data: DiskDownloadArgs, 13 | progressCallback: DownloadProgressCallback): Promise => { 14 | 15 | const messageData = { 16 | requests: [ 17 | { 18 | action: "write media by link", 19 | data 20 | } 21 | ], 22 | time: window.performance.now() 23 | }; 24 | 25 | let _resolve: Function = null; 26 | let _reject: Function = null; 27 | const downloadPromise = new Promise((res, rej) => { 28 | _resolve = res; 29 | _reject = rej; 30 | }); 31 | 32 | //long lived connection 33 | const port = runtime.connect({ 34 | name: "disk-downloader" 35 | }); 36 | port.onMessage.addListener((answer, sender) => { 37 | if (answer.origin === "native host disconnect") { 38 | _reject(answer.data); 39 | return; 40 | } 41 | else if (answer.origin === "native host response") { 42 | const resultEntry = answer.data[0]; 43 | const resultEntryType = resultEntry.type; 44 | if (resultEntryType === "success") { 45 | _resolve(); 46 | return; 47 | } 48 | else if (resultEntryType === "error") { 49 | _reject(resultEntry.data); 50 | return; 51 | } 52 | else if (resultEntryType === "progress"){ 53 | progressCallback(resultEntry.data.progress); 54 | } 55 | } 56 | 57 | const responseType = answer.data[0].type; 58 | if (responseType === "success"){ 59 | port.disconnect(); 60 | } 61 | }); 62 | port.postMessage(messageData); 63 | 64 | return downloadPromise; 65 | }; 66 | -------------------------------------------------------------------------------- /app/src/disk-writing/lookup-write-path.ts: -------------------------------------------------------------------------------- 1 | import { storage } from "webextension-polyfill"; 2 | 3 | interface FolderPathLookupArgs { 4 | mediaSrc: string, 5 | userName: string, 6 | ownUserName: string 7 | }; 8 | 9 | const getFolderPathByItems = (userName: string, ownUserName: string, items: any): string | null => { 10 | const directoryRules = items.directoryRules || []; 11 | directoryRules.reverse(); 12 | 13 | const baseDownloadDirectory = items.baseDownloadDirectory || ""; 14 | 15 | const unifiedRules = [ 16 | ...directoryRules, 17 | { baseDirectory: baseDownloadDirectory } 18 | ]; 19 | 20 | for (let rule of unifiedRules) { 21 | const usernamesToMatch = rule.username || []; 22 | const ownUsernamesToMatch = rule.downloadAs || []; 23 | const baseDirectory = rule.baseDirectory || ""; 24 | const folderPath = rule.folderPath || ""; 25 | const ruleApplies = 26 | (usernamesToMatch.length === 0 || usernamesToMatch.includes(userName)) && 27 | (ownUsernamesToMatch.length === 0 || ownUsernamesToMatch.includes(ownUserName)); 28 | if (!ruleApplies) { 29 | continue; 30 | } 31 | 32 | let writePath = null; 33 | if (baseDirectory !== "") { 34 | writePath = `${baseDirectory}/${userName}`; 35 | } 36 | if (folderPath !== "") { 37 | writePath = folderPath; 38 | } 39 | if (writePath !== null) { 40 | return writePath; 41 | } 42 | } 43 | return null; 44 | }; 45 | 46 | export async function getFolderPath(args: FolderPathLookupArgs) { 47 | const items = await storage.sync.get({ 48 | baseDownloadDirectory: "", 49 | directoryRules: [] 50 | }); 51 | 52 | const path = getFolderPathByItems(args.userName, args.ownUserName, items); 53 | if (!path) throw "no path found. have you set a path in the extension-options?"; 54 | 55 | return path; 56 | }; -------------------------------------------------------------------------------- /app/src/download-button-injection/cached-story-fetching.ts: -------------------------------------------------------------------------------- 1 | import { Either, isLeft, left, right } from "fp-ts/es6/Either"; 2 | import { isNone, none, Option, Some, some } from "fp-ts/es6/Option"; 3 | import { findStoryIdInUrl } from "../data-extraction/directly-in-browser/stories/story-id"; 4 | import { findCurrentStoryIndex } from "../data-extraction/directly-in-browser/stories/story-index"; 5 | import { getCurrentStoryType } from "../data-extraction/directly-in-browser/stories/story-type"; 6 | import { getUsernameOfStory } from "../data-extraction/directly-in-browser/stories/username"; 7 | import { getMediaInfoFromSingleItem } from "../data-extraction/from-fetch-response/fetch-media-data"; 8 | import { VideoOrImgItem } from "../data-extraction/from-fetch-response/types"; 9 | import { fetchCurrentUserStoryHighlightWithCurrentHeaders } from "../data-extraction/instagram-api/stories/user-stories-highlights"; 10 | import { fetchUserStoryDataWithCurrentHeaders } from '../data-extraction/instagram-api/stories/user-stories-main'; 11 | import { fetchUserInfoWithCurrentHeaders, UserInfo } from "../data-extraction/instagram-api/user-info"; 12 | import { MediaWriteInfo } from "../download-buttons/disk-download-button"; 13 | 14 | 15 | // ## types ## 16 | 17 | type StoryItem = VideoOrImgItem & { pk: string, id: string }; 18 | 19 | type StoryDataShared = { 20 | id: string, 21 | user: UserInfo, 22 | items: StoryItem[] 23 | } 24 | 25 | type HighlightsStoryData = ( 26 | StoryDataShared & 27 | { reel_type: "highlight_reel" } 28 | ) 29 | 30 | type MainStoryData = ( 31 | StoryDataShared & 32 | { reel_type: "user_reel" } 33 | ) 34 | 35 | type StoryData = (HighlightsStoryData | MainStoryData); 36 | 37 | type ReelType = "user_reel" | "highlight_reel"; 38 | 39 | function getHighlightID(item: HighlightsStoryData): string { 40 | return item.id.replace("highlight:", ""); 41 | } 42 | 43 | 44 | // ###################### 45 | 46 | 47 | async function fetchMainStoryData(){ 48 | const userNameEith = getUsernameOfStory(); 49 | if (isLeft(userNameEith)) return userNameEith; 50 | const userName = userNameEith.right; 51 | const userDataEith = await fetchUserInfoWithCurrentHeaders(userName); 52 | if (isLeft(userDataEith)) return userDataEith; 53 | const userData = userDataEith.right; 54 | const userID = userData.pk.toString(); 55 | const storyData = await fetchUserStoryDataWithCurrentHeaders(userID); 56 | if (isLeft(storyData)) return storyData; 57 | return right( 58 | storyData.right.reels_media[0] as MainStoryData 59 | ); 60 | } 61 | 62 | async function fetchHighlightStoryData() { 63 | const storyData = await fetchCurrentUserStoryHighlightWithCurrentHeaders(); 64 | if (isLeft(storyData)) return storyData; 65 | return right( 66 | storyData.right.reels_media[0] as HighlightsStoryData 67 | ); 68 | } 69 | 70 | async function fetchCurrentStoryData(): Promise> { 71 | const storyType = getCurrentStoryType(); 72 | if (storyType === "highlight_reel") { 73 | return fetchHighlightStoryData(); 74 | } 75 | else { 76 | return fetchMainStoryData(); 77 | } 78 | // console.log(`got the story-data! it took ${performance.now() - startTime} milliseconds.`, storyData); 79 | // return right(storyData); 80 | } 81 | 82 | function findMainStoryItem(storyData: MainStoryData): Either { 83 | // find the story-item by its id: 84 | const storyIdOpt = findStoryIdInUrl(); 85 | if (isNone(storyIdOpt)) return left(`could not story-id in url ${location.href}`); 86 | const storyID = storyIdOpt.value; 87 | const currentStoryItem = storyData.items.find(item => item.pk === storyID); 88 | if (!currentStoryItem) { 89 | console.log(storyData.items, storyID); 90 | return left(`could not find story-ID ${storyID} in any of the fetched story-items`); 91 | } 92 | return right(currentStoryItem); 93 | } 94 | 95 | function findHighlightStoryItem(storyData: HighlightsStoryData) { 96 | const itemIndex = findCurrentStoryIndex(); 97 | if (isLeft(itemIndex)) return itemIndex; 98 | return right( 99 | storyData.items[itemIndex.right] 100 | ); 101 | } 102 | 103 | function findCurrentStoryItem(storyData: StoryData) { 104 | if (storyData.reel_type === "user_reel"){ 105 | return findMainStoryItem(storyData); 106 | } 107 | else { 108 | return findHighlightStoryItem(storyData); 109 | } 110 | } 111 | 112 | function isStoryCacheStale(cachedData: StoryData): boolean { 113 | const currentStoryType = getCurrentStoryType(); 114 | if (currentStoryType !== cachedData.reel_type) return true; 115 | 116 | // compare the url-id with each cached story-item. if none matches, the cache is stale 117 | const storyIdOpt = findStoryIdInUrl(); 118 | if (isNone(storyIdOpt)){ 119 | console.warn("trying to check if the current story-cache is stale by searching for the current story item, given the urls id, but could not find an id in the url. are you sure you are watching a story?"); 120 | return true; 121 | } 122 | const storyID = storyIdOpt.value; 123 | return !cachedData.items.some(item => item.pk === storyID); 124 | } 125 | 126 | export function makeStoryFetcher() { 127 | let cachedStoryData: Option = none; 128 | 129 | return async (): Promise> => { 130 | 131 | if (isNone(cachedStoryData) || isStoryCacheStale(cachedStoryData.value)) { 132 | console.log("refreshing story cache"); 133 | const storyDataEith = await fetchCurrentStoryData(); 134 | if (isLeft(storyDataEith)) return storyDataEith; 135 | cachedStoryData = some(storyDataEith.right); 136 | } 137 | 138 | const storyData = (cachedStoryData as Some).value; 139 | const storyItemEith = findCurrentStoryItem(storyData); 140 | if (isLeft(storyItemEith)) return storyItemEith; 141 | const storyItem = storyItemEith.right; 142 | 143 | const mediaInfo = getMediaInfoFromSingleItem(storyItem); 144 | const { username } = storyData.user; 145 | return right({ 146 | src: mediaInfo.src, 147 | username 148 | }); 149 | } 150 | } -------------------------------------------------------------------------------- /app/src/download-button-injection/combined-download-extension.ts: -------------------------------------------------------------------------------- 1 | 2 | import instaChangeDetector from '../mutation-observer-posts-previews-stories'; 3 | import { injectDownloadButtonsIntoPost } from './post-button-injection'; 4 | import { injectDownloadButtonsIntoStory } from './story-extension'; 5 | 6 | 7 | instaChangeDetector.addEventListener( 8 | "onPostAdded", 9 | e => injectDownloadButtonsIntoPost((e as any).detail.element) 10 | ); 11 | 12 | // instaChangeDetector.addEventListener("onPreviewAdded", e => { 13 | // injectDownloadButtonsIntoPreview((e as any).detail.element); 14 | // }); 15 | 16 | instaChangeDetector.addEventListener( 17 | "onStoryAdded", 18 | e => injectDownloadButtonsIntoStory((e as any).detail.element) 19 | ); 20 | 21 | instaChangeDetector.start(); 22 | -------------------------------------------------------------------------------- /app/src/download-button-injection/preview-button-injection.ts: -------------------------------------------------------------------------------- 1 | // import { MediaWriteInfo, createDiskDownloadButton } from "../download-buttons/disk-download-button"; 2 | // import { fetchMediaInfo } from "../data-extraction/insta-info-util"; 3 | // import { createElementByHTML } from "../../lib/html-util"; 4 | 5 | // async function getMediaSrcOfPreviewElement(previewEl: HTMLElement): Promise { 6 | // const linkElement = previewEl.querySelector("a"); 7 | // if (linkElement === null) { 8 | // throw "link-element not found"; 9 | // } 10 | // let postHref = linkElement.href; 11 | 12 | // const data = await fetchMediaInfo(postHref); 13 | // let src = data.mediaArray[0].src; 14 | // let username = data.username; 15 | // return { username, src }; 16 | // }; 17 | 18 | // export const injectDownloadButtonsIntoPreview = (previewEl: HTMLElement) => { 19 | // const previewOverlay = createElementByHTML(` 20 | //
29 | //
30 | // `); 31 | // const getMediaSrc = () => getMediaSrcOfPreviewElement(previewEl); 32 | // const diskDownloadButton = createDiskDownloadButton(getMediaSrc); 33 | // Object.assign(diskDownloadButton.style, { 34 | // width: "24px", 35 | // height: "24px", 36 | // padding: "5px" 37 | // }); 38 | // previewOverlay.appendChild(diskDownloadButton); 39 | 40 | // previewEl.appendChild(previewOverlay); 41 | // }; -------------------------------------------------------------------------------- /app/src/download-button-injection/story-extension.ts: -------------------------------------------------------------------------------- 1 | import { isLeft, left, right } from "fp-ts/es6/Either"; 2 | import { createElementByHTML } from "../../lib/html-util"; 3 | import { createDiskDownloadButton } from "../download-buttons/disk-download-button"; 4 | import { downloadKey, requestDownloadByButton } from "../download-shortcut"; 5 | import { makeStoryFetcher } from "./cached-story-fetching"; 6 | 7 | 8 | export const injectDownloadButtonsIntoStory = (storyEl: HTMLElement) => { 9 | 10 | const container = createElementByHTML(` 11 |
12 | `); 13 | 14 | const pauseHandleDownloadOptions = (function(){ 15 | let pauseHandle: StoryPauseHandle | null = null; 16 | return { 17 | onDownloadStart: () => { 18 | if (document.querySelector("video")){ 19 | pauseHandle = createStoryPauseHandle(); 20 | pauseHandle.keepPaused(); 21 | } 22 | }, 23 | onDownloadEnd: (successful: boolean) => { 24 | if (!pauseHandle) return; 25 | pauseHandle.continue(); 26 | } 27 | }; 28 | })(); 29 | 30 | const diskDownloadButton = createDiskDownloadButton({ 31 | fetchMediaInfo: makeStoryFetcher(), 32 | ...pauseHandleDownloadOptions 33 | }); 34 | 35 | const targetSize = 24; 36 | // make the button a little smaller to better fit in with its siblings: 37 | Object.assign( 38 | diskDownloadButton.style, 39 | { width: `${targetSize}px` } 40 | ); 41 | 42 | // Object.assign(diskDownloadButton.style, getStoryDownloadElementStyle(storyEl)); 43 | container.appendChild(diskDownloadButton); 44 | 45 | const playButtonEither = findStoryPlayButton(storyEl); 46 | if (isLeft(playButtonEither)){ 47 | console.warn(playButtonEither.left); 48 | return; 49 | } 50 | const playButton = playButtonEither.right; 51 | 52 | const buttonContainer = playButton.parentElement; 53 | if (!buttonContainer){ 54 | console.error("the playButton has no parentElement."); 55 | } 56 | else { 57 | buttonContainer.insertAdjacentElement("afterbegin", container); 58 | } 59 | 60 | document.addEventListener("keypress", e => { 61 | if (e.key === downloadKey){ 62 | requestDownloadByButton(diskDownloadButton); 63 | } 64 | }); 65 | }; 66 | 67 | function findStoryPlayButton(parent: HTMLElement) { 68 | // here we're relying on the language being set to english, 69 | // since the aria-label depends on language! 70 | const playButton = ( 71 | parent.querySelector('*[aria-label=Play]') ?? 72 | parent.querySelector('*[aria-label=Pause]') 73 | ); 74 | 75 | if (!playButton) { 76 | return left("could not add download-button in story. the svg for the pause/play button has no button as an ancestor"); 77 | } 78 | 79 | const playButtonParent = playButton.parentElement?.parentElement; 80 | if (!playButtonParent){ 81 | return left("found the playbutton but it has no grandparent which is very weird and will probably never ever happen"); 82 | } 83 | 84 | if (playButtonParent.getAttribute("role") !== "button"){ 85 | console.warn("the grandparent of this playButton has no role attribute with value 'button'. this is unexpected, but not necessarily breaking"); 86 | } 87 | 88 | return right(playButtonParent); 89 | } 90 | 91 | 92 | 93 | // # story pausing ----------------------- 94 | // (to prevent the story from finishing before the download started) 95 | 96 | interface StoryPauseHandle { 97 | keepPaused: () => void, 98 | continue: () => void 99 | }; 100 | 101 | function createStoryPauseHandle(): StoryPauseHandle { 102 | let _storyPaused = false; 103 | const video = document.querySelector("video"); 104 | const keepStoryPaused = () => { 105 | _storyPaused = true; 106 | const loop = () => { 107 | if (video && !video.paused) { 108 | video.pause(); 109 | } 110 | if (!_storyPaused) return; 111 | window.requestAnimationFrame(loop); 112 | }; 113 | loop(); 114 | }; 115 | const continueStory = () => { 116 | _storyPaused = false; 117 | }; 118 | return { 119 | keepPaused: keepStoryPaused, 120 | continue: continueStory 121 | } 122 | }; -------------------------------------------------------------------------------- /app/src/download-buttons/disk-download-button.ts: -------------------------------------------------------------------------------- 1 | import { Either, isLeft, right } from "fp-ts/es6/Either"; 2 | import { identity } from "fp-ts/es6/function"; 3 | import { runtime } from "webextension-polyfill"; 4 | import { createFileNameByUrl } from "../../lib/url-to-filename"; 5 | import { download as downloadByChrome } from '../disk-writing/chrome-download'; 6 | import { DownloadFeedbackButton } from "./download-feedback-button"; 7 | import { getIconUrl } from "./icon-url"; 8 | import { MediaFetchFn } from "../media-fetch-fn"; 9 | 10 | 11 | // defining some handy types first: 12 | 13 | export interface MediaWriteInfo { 14 | src: string, 15 | username: string 16 | }; 17 | 18 | export type LoadingCallback = (progress: number) => void; 19 | 20 | 21 | 22 | // # download by given media info (filename, url) 23 | 24 | type DownloadArgs = { 25 | mediaInfo: MediaWriteInfo, 26 | loadingCallback: LoadingCallback 27 | } 28 | 29 | async function tryDownloadMedia(args: DownloadArgs): Promise> { 30 | const { mediaInfo, loadingCallback } = args; 31 | const mediaSrc = mediaInfo.src; 32 | 33 | const fileNameEither = createFileNameByUrl(mediaSrc); 34 | if (isLeft(fileNameEither)){ 35 | return fileNameEither; 36 | } 37 | 38 | const fileName = fileNameEither.right; 39 | 40 | await downloadByChrome( 41 | { 42 | filePath: `Instagram/${mediaInfo.username}/${fileName}`, 43 | url: mediaSrc 44 | }, 45 | loadingCallback 46 | ); 47 | 48 | return right(undefined); 49 | }; 50 | 51 | 52 | 53 | // # first try to fetch the media info and then try to download it. 54 | // if anything goes wrong, show an error message: 55 | 56 | type FetchAndDownloadArgs = { 57 | fetchMediaInfo: MediaFetchFn, 58 | loadingCallback: LoadingCallback 59 | } 60 | 61 | function dispatchDownloadErrorMessage(message: string){ 62 | runtime.sendMessage({ 63 | type: "show-notification", 64 | notification: { 65 | title: "download failed", 66 | message, 67 | iconUrl: getIconUrl("insta-loader-icon-48") 68 | } 69 | }); 70 | } 71 | 72 | async function tryFetchAndDownloadMediaWithErrorFeedback(args: FetchAndDownloadArgs): Promise { 73 | const { fetchMediaInfo: getMediaInfo, loadingCallback } = args; 74 | 75 | const mediaInfoEith = await getMediaInfo(); 76 | if (isLeft(mediaInfoEith)){ 77 | console.error(mediaInfoEith.left); 78 | dispatchDownloadErrorMessage( 79 | "something went wrong while trying to fetch the image or video. sorry for this vague message. i'm trying to provide better messages in future releases." 80 | ); 81 | return false; 82 | } 83 | 84 | const mediaInfo = mediaInfoEith.right; 85 | 86 | // now that we've successfully fetched the media info, 87 | // try to download it: 88 | const downloadResult = await tryDownloadMedia({ 89 | mediaInfo, loadingCallback 90 | }); 91 | 92 | // handle failure to download 93 | if (isLeft(downloadResult)){ 94 | const downloadFail = downloadResult.left; 95 | console.error(downloadFail); 96 | dispatchDownloadErrorMessage( 97 | `we've successfully figured out the download url and username, but the download failed anyway. not exactly sure why. i will try to include the exact reason soon. you may want to try downloading this file on your own. here's the url:\n${mediaInfo.src}\nusername: ${mediaInfo.username}` 98 | ); 99 | return false; 100 | } 101 | 102 | return true; 103 | }; 104 | 105 | 106 | 107 | // # download button 108 | 109 | export type DiskDownloadButtonOptions = { 110 | fetchMediaInfo: MediaFetchFn, 111 | onDownloadStart?: VoidCallback, 112 | onDownloadEnd?: (downloadSuccessful: boolean) => void 113 | }; 114 | 115 | export const createDiskDownloadButton = (options: DiskDownloadButtonOptions): HTMLElement => { 116 | 117 | const { 118 | fetchMediaInfo, 119 | onDownloadStart = identity, 120 | onDownloadEnd = identity 121 | } = options; 122 | 123 | const buttonWrapper = new DownloadFeedbackButton(); 124 | const buttonEl = buttonWrapper.getElement(); 125 | 126 | const startDownload = async () => { 127 | buttonWrapper.downloadState = "loading"; 128 | 129 | const updateProgress = (progress: number) => { 130 | buttonWrapper.loadingProgress = progress; 131 | }; 132 | 133 | onDownloadStart(undefined); 134 | 135 | const wasDownloadSuccessful = await tryFetchAndDownloadMediaWithErrorFeedback({ 136 | fetchMediaInfo, 137 | loadingCallback: updateProgress 138 | }); 139 | 140 | buttonWrapper.downloadState = wasDownloadSuccessful ? "success" : "fail"; 141 | 142 | onDownloadEnd(wasDownloadSuccessful); 143 | }; 144 | 145 | buttonEl.addEventListener("download-request", startDownload); 146 | 147 | buttonEl.addEventListener("mousedown", startDownload); 148 | 149 | return buttonEl; 150 | }; -------------------------------------------------------------------------------- /app/src/download-buttons/download-feedback-button.ts: -------------------------------------------------------------------------------- 1 | import { getElementTypesOnCurrentPage } from '../data-extraction/is-currently-post-story-or-preview'; 2 | import { getIconUrl } from './icon-url'; 3 | 4 | 5 | type DownloadState = "initial" | "loading" | "success" | "fail"; 6 | 7 | const iconNames = { 8 | initial: "save", 9 | loading: "spinner-of-dots", 10 | success: "verify-sign-green", 11 | fail: "error" 12 | }; 13 | 14 | export class DownloadFeedbackButton { 15 | 16 | _downloadState: DownloadState = "initial"; 17 | get downloadState(): DownloadState { 18 | return this._downloadState; 19 | } 20 | set downloadState(val: DownloadState){ 21 | this._downloadState = val; 22 | this._onDownloadStateChanged(); 23 | } 24 | 25 | _loadingProgress: number = 0; 26 | get loadingProgress(): number { 27 | return this._loadingProgress; 28 | } 29 | set loadingProgress(val: number) { 30 | this._loadingProgress = val; 31 | if (this._downloadState === "loading"){ 32 | this._drawSpinner(); 33 | } 34 | } 35 | 36 | _spinnerCtx = null; 37 | _spinnerCanvas = null; 38 | _buttonImg = null; 39 | _rootElement = null; 40 | 41 | constructor(){ 42 | this._rootElement = document.createElement("a"); 43 | this._rootElement.classList.add("download-button"); 44 | Object.assign(this._rootElement.style, { 45 | width: "fit-content", 46 | height: "fit-content", 47 | cursor: "pointer" 48 | }); 49 | this._buttonImg = document.createElement("img"); 50 | Object.assign(this._buttonImg.style, { 51 | width: "inherit", 52 | height: "inherit" 53 | }); 54 | this._rootElement.appendChild(this._buttonImg); 55 | this._setInitialState(); 56 | } 57 | getElement(): HTMLElement { 58 | return this._rootElement; 59 | } 60 | _setInitialState(){ 61 | let elementType = getElementTypesOnCurrentPage()[0]; 62 | let iconAppendix = elementType == "post" ? "dark" : "white"; 63 | const iconName = `${iconNames["initial"]}-${iconAppendix}`; 64 | this._buttonImg.src = getIconUrl(iconName) 65 | } 66 | _onDownloadStateChanged(){ 67 | const state = this._downloadState; 68 | if (state === "loading"){ 69 | //lazily instantiate canvas 70 | if (!this._spinnerCanvas){ 71 | this._spinnerCanvas = document.createElement("canvas"); 72 | this._spinnerCtx = this._spinnerCanvas.getContext("2d"); 73 | this._drawSpinner(); 74 | } 75 | } 76 | else { 77 | this._spinnerCanvas = null; 78 | this._spinnerCtx = null; 79 | 80 | let iconName = iconNames[state]; 81 | if (state === "initial") { 82 | let elementType = getElementTypesOnCurrentPage()[0]; 83 | let iconAppendix = elementType == "post" ? "dark" : "white"; 84 | iconName += `-${iconAppendix}`; 85 | } 86 | this._buttonImg.src = getIconUrl(iconName); 87 | } 88 | } 89 | _drawSpinner(){ 90 | const ctx = this._spinnerCtx; 91 | const progress = this._loadingProgress; 92 | const squareSize = 32; 93 | Object.assign(this._spinnerCanvas, { 94 | width: squareSize, 95 | height: squareSize 96 | }); 97 | ctx.clearRect(0, 0, squareSize, squareSize); 98 | ctx.lineWidth = 4; 99 | const radius = (squareSize - ctx.lineWidth) / 2; 100 | ctx.strokeStyle = "cyan"; 101 | ctx.lineCap = "round"; 102 | ctx.beginPath(); 103 | const squareSizeHalf = squareSize / 2; 104 | const angleOffset = -Math.PI / 2; 105 | ctx.arc(squareSizeHalf, squareSizeHalf, radius, angleOffset, angleOffset + progress * 2 * Math.PI); 106 | ctx.stroke(); 107 | 108 | this._buttonImg.src = this._spinnerCanvas.toDataURL(); 109 | } 110 | }; -------------------------------------------------------------------------------- /app/src/download-buttons/icon-url.ts: -------------------------------------------------------------------------------- 1 | import { runtime } from 'webextension-polyfill'; 2 | 3 | export function getIconUrl(iconName: string): string { 4 | return runtime.getURL(`assets/icons/${iconName}.png`); 5 | } -------------------------------------------------------------------------------- /app/src/download-buttons/link-button.ts: -------------------------------------------------------------------------------- 1 | import { createElementByHTML } from "../../lib/html-util"; 2 | import { getIconUrl } from "./icon-url"; 3 | 4 | export function makeLinkButton(href: string){ 5 | return createElementByHTML(` 6 |
15 | 19 | 23 | 24 |
25 | `); 26 | } -------------------------------------------------------------------------------- /app/src/download-buttons/prompt-download-button.ts: -------------------------------------------------------------------------------- 1 | import { createFileNameByUrl } from "../../lib/url-to-filename"; 2 | import { downloadResource } from "../../lib/prompt-download-util"; 3 | import { createElementByHTML } from "../../lib/html-util"; 4 | import { isLeft } from "fp-ts/es6/Either"; 5 | import { getIconUrl } from "./icon-url"; 6 | import { getElementTypesOnCurrentPage, InstaElementType } from "../data-extraction/is-currently-post-story-or-preview"; 7 | 8 | const getDownloadIconSrc = (iconAppendix: string): string => { 9 | return getIconUrl(`download-icon-${iconAppendix}`); 10 | }; 11 | const getPromptDownloadIcon = (type: InstaElementType): string => { 12 | let elementTypes = getElementTypesOnCurrentPage(); 13 | let elementType = elementTypes[0]; 14 | let iconAppendixMap = { 15 | preview: "white", 16 | post: "dark", 17 | story: "white" 18 | }; 19 | let iconAppendix = iconAppendixMap[elementType]; 20 | let src = getDownloadIconSrc(iconAppendix); 21 | return src; 22 | }; 23 | const downloadFileDirectly = async (getMediaSrc: () => Promise) => { 24 | try { 25 | const src = await getMediaSrc(); 26 | const fileNameEither = createFileNameByUrl(src); 27 | if (isLeft(fileNameEither)){ 28 | throw fileNameEither.left; 29 | } 30 | await downloadResource(src, fileNameEither.right); 31 | } 32 | catch (e) { 33 | console.error(e); 34 | } 35 | }; 36 | const createPromptDownloadButton = (getMediaSrc: () => Promise): HTMLElement => { 37 | const button = createElementByHTML(` 38 | 39 | 40 | 41 | `); 42 | button.addEventListener("click", () => downloadFileDirectly(getMediaSrc)); 43 | 44 | return button; 45 | }; -------------------------------------------------------------------------------- /app/src/download-shortcut.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * on the main feed, use the `enter` key to quickly start downloading the currently visible post. 4 | */ 5 | 6 | import { findCurrentPost } from "./navigation-by-keys/find-current-post"; 7 | 8 | export const downloadKey = "Enter"; 9 | 10 | export function requestDownloadByButton(downloadButton: HTMLElement){ 11 | downloadButton.dispatchEvent(new CustomEvent("download-request")); 12 | } 13 | 14 | function findDownloadButton(postEl: HTMLElement): HTMLElement | null { 15 | return postEl.querySelector(".download-button"); 16 | } 17 | 18 | function downloadCurrentPostMedia(){ 19 | const curPost = findCurrentPost(); 20 | if (!curPost){ 21 | console.warn("download by shortcut: could not find the currently visible post to download"); 22 | return; 23 | } 24 | const downloadButton = findDownloadButton(curPost); 25 | if (downloadButton === null) { 26 | console.warn("download by shortcut: found the currently visible post, but there seems to be no download button. are you sure it's there?"); 27 | return; 28 | } 29 | requestDownloadByButton(downloadButton); 30 | } 31 | 32 | document.addEventListener("keydown", e => { 33 | if (e.key !== downloadKey) return 34 | downloadCurrentPostMedia(); 35 | }) -------------------------------------------------------------------------------- /app/src/index.ts: -------------------------------------------------------------------------------- 1 | import './download-button-injection/combined-download-extension'; 2 | import './navigation-by-keys/navigation-setup'; 3 | import './download-shortcut'; 4 | import './data-extraction/instagram-api/request-header-collection/foreground-collector'; 5 | import './data-extraction/instagram-api/stories/user-stories-main'; 6 | 7 | console.log("### insta loader initialized ###"); -------------------------------------------------------------------------------- /app/src/insta-navigation-observer.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | type InstaPageType = "mainFeed" | "post" | "personFeed" | "stories" | "reel"; 4 | 5 | //missing: 6 | //tagFeed 7 | //savedFeed 8 | export const pageType: { [key: string]: InstaPageType } = { 9 | mainFeed: "mainFeed", 10 | post: "post", 11 | personFeed: "personFeed", 12 | stories: "stories" 13 | }; 14 | export const getCurrentPageType = (): InstaPageType => { 15 | let href = window.location.href; 16 | if (href.endsWith("instagram.com/") || href.includes(".com/?")) { 17 | return pageType.mainFeed; 18 | } 19 | else if (href.includes("/p/")) { 20 | return pageType.post; 21 | } 22 | else if (href.includes("/reel/")){ 23 | return "reel"; 24 | } 25 | else if (href.includes("/stories/")) { 26 | return pageType.stories; 27 | } 28 | else { 29 | return pageType.personFeed; 30 | } 31 | }; 32 | 33 | export const isSinglePostType = (type: InstaPageType) => ( 34 | type === "post" || type === "reel" 35 | ); 36 | 37 | interface InstaNavigationChangeData { 38 | oldHref: string, 39 | newHref: string, 40 | oldPageType: InstaPageType, 41 | newPageType: InstaPageType 42 | }; 43 | type InstaNavigationCallback = (data: InstaNavigationChangeData) => void; 44 | 45 | const onNavigation: InstaNavigationCallback[] = []; 46 | export const subscribeToNavigation = (callback: InstaNavigationCallback) => { 47 | onNavigation.push(callback); 48 | }; 49 | const invokeViewModeChangeListener = (data: InstaNavigationChangeData) => { 50 | onNavigation.forEach(callback => callback(data)); 51 | }; 52 | 53 | //main feed || stories || other 54 | let currentHref: string = window.location.href; 55 | let currentPageType: InstaPageType = getCurrentPageType(); 56 | const reactRoot: Element = document.querySelector("#mount_0_0_8N"); 57 | const onRootMutation = () => { 58 | const newHref = window.location.href; 59 | if (newHref === currentHref) { 60 | return; 61 | } 62 | const oldHref = currentHref; 63 | const oldPageType = currentPageType; 64 | currentHref = newHref; 65 | currentPageType = getCurrentPageType(); 66 | const naviData: InstaNavigationChangeData = { 67 | oldHref, newHref, 68 | oldPageType, 69 | newPageType: currentPageType 70 | }; 71 | invokeViewModeChangeListener(naviData); 72 | }; 73 | // (new MutationObserver(onRootMutation)).observe(reactRoot, { childList: true, subtree: true }) -------------------------------------------------------------------------------- /app/src/media-fetch-fn.ts: -------------------------------------------------------------------------------- 1 | import { Either } from "fp-ts/es6/Either"; 2 | import { Lazy } from "fp-ts/es6/function"; 3 | import { MediaWriteInfo } from "./download-buttons/disk-download-button"; 4 | 5 | type MediaFetchFail = { 6 | userFriendlyMessage: string, 7 | consoleLoggable: any 8 | } 9 | 10 | // TODO: replace `any` by `MediaFetchFail` 11 | export type MediaFetchFn = Lazy>>; -------------------------------------------------------------------------------- /app/src/mutation-observer-posts-previews-stories.ts: -------------------------------------------------------------------------------- 1 | 2 | import { flow, pipe } from 'fp-ts/es6/function'; 3 | import { getElementTypesOnCurrentPage, InstaElementType } from './data-extraction/is-currently-post-story-or-preview'; 4 | import { getCurrentPageType } from './insta-navigation-observer'; 5 | import { findInAncestors } from '../lib/find-dom-ancestor'; 6 | import { isSome } from 'fp-ts/es6/Option'; 7 | 8 | 9 | /** 10 | * this module exposes an object `instaChangeDetector` with which you can do the following: 11 | * ```typescript 12 | * instaChangeDetector.addEventListener( 13 | * "onPostAdded", 14 | * e => injectDownloadButtonsIntoPost((e as any).detail.element) 15 | * ); 16 | * 17 | * instaChangeDetector.addEventListener("onStoryAdded", addDownloadButtonsToStory); 18 | * 19 | * instaChangeDetector.addEventListener("onPreviewAdded", addDownloadButtonsToPreview); 20 | * ``` 21 | */ 22 | 23 | 24 | interface InstaChangeDetector extends EventTarget { 25 | start: () => void, 26 | }; 27 | 28 | const instaChangeDetector = new EventTarget() as InstaChangeDetector; 29 | 30 | 31 | 32 | // ## queries 33 | 34 | function getParentElements(elements: HTMLElement[]): HTMLElement[] { 35 | return elements 36 | .map(el => el.parentElement) 37 | .filter(el => el !== null) as HTMLElement[]; 38 | } 39 | 40 | const queryPreviewElements = flow( 41 | (root: HTMLElement) => root.querySelectorAll('a[href*="/p/"]'), 42 | Array.from, 43 | getParentElements 44 | ); 45 | 46 | function queryPostElements(element: HTMLElement): HTMLElement[] { 47 | // console.log("queryPostElements", "pagetype", getCurrentPageType(), element); 48 | 49 | // article elements seem to only appear on the main feed. 50 | if ( getCurrentPageType() === "mainFeed" ){ 51 | if (element.tagName == "ARTICLE") return [ element ]; 52 | const articles = Array.from( element.querySelectorAll("article") ); 53 | if (articles.length > 0) return articles; 54 | } 55 | else { 56 | // on single post pages, it seems that article elements have been replaced by main elements. 57 | if (element.tagName == "MAIN") return [ element ]; 58 | const mains = Array.from( element.querySelectorAll("main") ); 59 | return mains; 60 | } 61 | 62 | return []; 63 | } 64 | 65 | // TODO: if you paste a link to a story in your url bar and press enter, 66 | // it will ask you if you want to view the story with your current account. 67 | // the question appears in the same place as the story and all of the 68 | // controls, including the play button, are missing. 69 | // if you press 'confirm', the story loads, but the download button does not. 70 | // the problem is with the question overlay. it is not recognized as a 71 | // story element by this function and even if it was, it wouldn't be able 72 | // to find the download button. 73 | function queryStoryElements(root: HTMLElement): HTMLElement[] { 74 | // the story element is a
with classes _s7gs2 _d9zua (11.04.2018) 75 | // it has a header, and an explicit width 76 | if ( 77 | root.matches("section") && 78 | Array.from(root.children).findIndex(el => el.tagName == "HEADER") > 0 79 | ){ 80 | // stories on the mainfeed seem to have a header element with 81 | // the authors name, the play button, the mute button, etc. 82 | return [root]; 83 | } 84 | 85 | // otherwise, the header element is a div. 86 | // we can check if it contains an element with aria-label=Menu, 87 | // which is a button with 3 horizontal dots. 88 | // checking for aria-label requires the language to be fixed! 89 | // for example in german, the aria-label may be different 90 | // than in english. 91 | if (root.querySelector("*[aria-label=Menu]")){ 92 | const result = findInAncestors( 93 | (el) => el.matches("section"), 94 | root 95 | ); 96 | if (isSome(result)){ 97 | return [result.value]; 98 | } 99 | } 100 | 101 | return pipe( 102 | root.querySelectorAll("header"), 103 | Array.from, 104 | getParentElements, 105 | ); 106 | } 107 | 108 | 109 | // ## observer entries 110 | 111 | const invokeListener = (name: string, element: HTMLElement) => { 112 | instaChangeDetector.dispatchEvent( 113 | new CustomEvent(name, { detail: { element } }) 114 | ) 115 | }; 116 | 117 | class ObservedElementType { 118 | getContainedElements: (parent: HTMLElement) => HTMLElement[]; 119 | elementType: InstaElementType; 120 | 121 | constructor( 122 | elementType: InstaElementType, 123 | queryElements: (parent: HTMLElement) => HTMLElement[]){ 124 | 125 | this.getContainedElements = queryElements; 126 | this.elementType = elementType; 127 | } 128 | 129 | matchesType(elementTypes: InstaElementType[]): boolean { 130 | return elementTypes.includes(this.elementType); 131 | } 132 | 133 | buildEventName(postFix: string){ 134 | const elType = this.elementType; 135 | const middle = elType[0].toUpperCase() + elType.slice(1); 136 | return `on${middle}${postFix}`; 137 | } 138 | 139 | onAdded(addedElement: HTMLElement){ 140 | invokeListener(this.buildEventName("Added"), addedElement); 141 | } 142 | 143 | onRemoved(removedElement: HTMLElement) { 144 | invokeListener(this.buildEventName("Removed"), removedElement); 145 | } 146 | } 147 | 148 | const observedElementTypes = [ 149 | new ObservedElementType("post", queryPostElements), 150 | new ObservedElementType("preview", queryPreviewElements), 151 | new ObservedElementType("story", queryStoryElements), 152 | ]; 153 | 154 | 155 | 156 | //mutation observer ### 157 | 158 | function onNodeExistenceChanged(node: HTMLElement, added: boolean){ 159 | if (node.nodeType != 1) return; 160 | 161 | let elementTypes = getElementTypesOnCurrentPage(); 162 | for (let observer of observedElementTypes){ 163 | if (!observer.matchesType(elementTypes)) continue; 164 | 165 | let contained = observer.getContainedElements(node); 166 | let foreachFunc = added ? observer.onAdded.bind(observer) : observer.onRemoved.bind(observer); 167 | contained.forEach(foreachFunc); 168 | } 169 | } 170 | 171 | function onNodeAdded(addedNode: Node){ 172 | if (!(addedNode instanceof HTMLElement)) return; 173 | onNodeExistenceChanged(addedNode, true); 174 | } 175 | 176 | function onNodeRemoved(removedNode: Node){ 177 | if (!(removedNode instanceof HTMLElement)) return; 178 | onNodeExistenceChanged(removedNode, false); 179 | } 180 | 181 | function onMutation(mutation: MutationRecord){ 182 | mutation.addedNodes.forEach(onNodeAdded); 183 | mutation.removedNodes.forEach(onNodeRemoved); 184 | } 185 | 186 | function onMutations(mutationArray: MutationRecord[]){ 187 | mutationArray.forEach(onMutation); 188 | } 189 | 190 | function startObservation(){ 191 | var observer = new MutationObserver(onMutations); 192 | observer.observe(document, { childList: true, subtree: true }); 193 | } 194 | 195 | function initCurrentElements(){ 196 | onNodeAdded(document.body); 197 | } 198 | 199 | 200 | 201 | function start(){ 202 | initCurrentElements(); 203 | startObservation(); 204 | } 205 | instaChangeDetector.start = start; 206 | 207 | export default instaChangeDetector; -------------------------------------------------------------------------------- /app/src/navigation-by-keys/find-current-post.ts: -------------------------------------------------------------------------------- 1 | import { findMainFeedPosts } from "./find-mainfeed-posts"; 2 | 3 | 4 | function calculatePostDistanceToViewport(postEl: HTMLElement): number { 5 | const rect = postEl.getBoundingClientRect(); 6 | const centerY = (rect.top + rect.bottom) / 2; 7 | return Math.abs(centerY - window.innerHeight / 2); 8 | } 9 | 10 | export function findCurrentPost() { 11 | const posts = findMainFeedPosts(); 12 | if (posts.length === 0) return null; 13 | 14 | const closestPostData: [number, HTMLElement | null] = posts.reduce( 15 | (acc: [number, HTMLElement | null], postEl: HTMLElement) => { 16 | const dist = calculatePostDistanceToViewport(postEl); 17 | return dist < acc[0] ? [dist, postEl] as [number, HTMLElement] : acc 18 | }, 19 | [Infinity, null] 20 | ); 21 | return closestPostData[1]; 22 | } -------------------------------------------------------------------------------- /app/src/navigation-by-keys/find-mainfeed-posts.ts: -------------------------------------------------------------------------------- 1 | 2 | function findMainFeedPostsContainer() { 3 | return document.querySelector("article")?.parentElement; 4 | } 5 | 6 | export function findMainFeedPosts(): HTMLElement[] { 7 | const mainPostContainer = findMainFeedPostsContainer(); 8 | if (!mainPostContainer) return []; 9 | return Array.from(mainPostContainer.children) as HTMLElement[] 10 | } -------------------------------------------------------------------------------- /app/src/navigation-by-keys/horizontal-navigation.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentPageType } from "../insta-navigation-observer"; 2 | import { findCurrentPost } from "./find-current-post"; 3 | 4 | 5 | type NavigationDirection = "left" | "right"; 6 | 7 | const keyToDirection: Record = { 8 | "a": "left", "d": "right" 9 | }; 10 | 11 | const directionToNaviButtonClass: Record = { 12 | "left": "LeftChevron", 13 | "right": "RightChevron" 14 | }; 15 | 16 | function logNavigationFailWarning(){ 17 | console.warn("you've tried to navigate to the next or previous image/video but we couldn't find the button to click on"); 18 | } 19 | 20 | function navigate(direction: NavigationDirection){ 21 | const pageType = getCurrentPageType(); 22 | const parentElement = pageType === "stories" ? document.body : findCurrentPost(); 23 | if (!parentElement){ 24 | logNavigationFailWarning(); 25 | return; 26 | } 27 | const naviClass = directionToNaviButtonClass[direction]; 28 | const naviEl1 = parentElement.querySelector(`[class*="${naviClass}"]`); 29 | if (!naviEl1){ 30 | logNavigationFailWarning(); 31 | return; 32 | } 33 | const naviButton = naviEl1.parentElement; 34 | if (!naviButton){ 35 | logNavigationFailWarning(); 36 | return; 37 | } 38 | naviButton.click(); 39 | } 40 | 41 | document.addEventListener("keydown", e => { 42 | const { key } = e; 43 | if (!(key in keyToDirection)) return; 44 | navigate(keyToDirection[key]); 45 | }); -------------------------------------------------------------------------------- /app/src/navigation-by-keys/navigation-setup.ts: -------------------------------------------------------------------------------- 1 | import './vertical-navigation'; 2 | import './horizontal-navigation'; -------------------------------------------------------------------------------- /app/src/navigation-by-keys/vertical-navigation.ts: -------------------------------------------------------------------------------- 1 | import { isNone, none, Option, some } from "fp-ts/es6/Option"; 2 | import { findMainFeedPosts } from "./find-mainfeed-posts"; 3 | 4 | const scrollTolerance = 30; 5 | 6 | function calculateWindowBottomToDownloadBottomDifference(postEl: HTMLElement): Option { 7 | const downloadButtonContainer = postEl.querySelector("section"); 8 | if (downloadButtonContainer === null){ 9 | console.warn(`trying to calculate distance to bottom, cannot find a 'section' element in post`); 10 | return none; 11 | } 12 | const downloadButtonBottom = downloadButtonContainer.getBoundingClientRect().bottom; 13 | const windowBottom = window.innerHeight; 14 | return some(windowBottom - downloadButtonBottom); 15 | } 16 | 17 | function isNextPost(postEl: HTMLElement): boolean { 18 | const diffOpt = calculateWindowBottomToDownloadBottomDifference(postEl); 19 | if (isNone(diffOpt)) return false; 20 | return diffOpt.value < -scrollTolerance 21 | } 22 | function isPrevPost(postEl: HTMLElement): boolean { 23 | const diffOpt = calculateWindowBottomToDownloadBottomDifference(postEl); 24 | if (isNone(diffOpt)) return false; 25 | return diffOpt.value > scrollTolerance 26 | } 27 | 28 | function findNextPost(): HTMLElement | undefined { 29 | return findMainFeedPosts().find(isNextPost) 30 | } 31 | function findPrevPost(): HTMLElement | undefined { 32 | return findMainFeedPosts().reverse().find(isPrevPost) 33 | } 34 | 35 | type PostFindFunc = () => HTMLElement | undefined; 36 | 37 | function scrollToPost(findFunc: PostFindFunc){ 38 | const postEl = findFunc(); 39 | if (!postEl){ 40 | console.warn("could not find post to scroll to"); 41 | return; 42 | } 43 | const scrollDelta = -calculateWindowBottomToDownloadBottomDifference(postEl); 44 | const targetScrollTop = window.scrollY + scrollDelta; 45 | window.scrollTo({ 46 | left: window.scrollX, 47 | top: targetScrollTop, 48 | behavior: "smooth" 49 | }); 50 | } 51 | 52 | function initNavigation(){ 53 | 54 | const navigationKeys = { 55 | "scroll-up": "w", 56 | "scroll-down": "s" 57 | } 58 | 59 | document.addEventListener("keydown", e => { 60 | if (e.key === navigationKeys["scroll-up"]){ 61 | scrollToPost(findPrevPost); 62 | } 63 | else if (e.key === navigationKeys["scroll-down"]){ 64 | scrollToPost(findNextPost); 65 | } 66 | }); 67 | } 68 | 69 | initNavigation(); -------------------------------------------------------------------------------- /app/src/notifications-background.ts: -------------------------------------------------------------------------------- 1 | import { runtime, notifications } from "webextension-polyfill"; 2 | 3 | interface NotificationArgs { 4 | iconUrl: string, 5 | title: string, 6 | message: string 7 | }; 8 | 9 | runtime.onMessage.addListener( 10 | (request) => { 11 | if (request.type !== "show-notification") return; 12 | notifications.create( 13 | undefined, 14 | { 15 | type: "basic", 16 | ...request.notification 17 | } 18 | ); 19 | } 20 | ); -------------------------------------------------------------------------------- /app/src/options/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | InstaLoader Options 5 | 27 | 28 | 29 | 30 |
31 | download directory: 32 | 33 |
34 |
35 | download method: 36 | 40 |
41 |
42 |

rules:

43 |
44 |
45 | 46 |
47 |
48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /app/src/options/options.js: -------------------------------------------------------------------------------- 1 | 2 | const { sync } = chrome.storage; 3 | 4 | //rules ### 5 | //downloaded as, username, baseDirectory, folderPath 6 | const ruleEntryClasses = {}; 7 | const getRuleEntryTagNameByKey = key => { 8 | return ruleEntryClasses[key]; 9 | }; 10 | const getRuleEntryKeyByTagName = tagName => { 11 | return Reflect.ownKeys(ruleEntryClasses).find(key => ruleEntryClasses[key] === tagName); 12 | }; 13 | 14 | class RuleEntry extends HTMLElement { 15 | constructor(){ 16 | super(); 17 | this.attachShadow({ mode: "open" }); 18 | this.shadowRoot.innerHTML = ` 19 | 26 | ${this.getContentHTML()} 27 | 28 | `; 29 | this.shadowRoot.querySelector("#delete-button").addEventListener("click", e => { 30 | this.remove() 31 | saveRules(); 32 | }); 33 | } 34 | getContentHTML(){ 35 | return ""; 36 | } 37 | static getRuleLabel(){ 38 | return ""; 39 | } 40 | getRuleValue(){ 41 | return null; 42 | } 43 | setRuleValue(newRuleValue){ 44 | this._currentRuleValue = newRuleValue; 45 | } 46 | } 47 | class TextRuleEntry extends RuleEntry { 48 | constructor(){ 49 | super(); 50 | this.shadowRoot.querySelector("#rule-value-input").addEventListener("change", () => saveRules()); 51 | } 52 | getContentHTML(){ 53 | return ` 54 | 55 | 56 | `; 57 | } 58 | static getRuleLabel(){ 59 | return ""; 60 | } 61 | getRuleValue(){ 62 | return this.shadowRoot.querySelector("#rule-value-input").value; 63 | } 64 | setRuleValue(newRuleValue){ 65 | return this.shadowRoot.querySelector("#rule-value-input").value = newRuleValue; 66 | } 67 | } 68 | class CommaSeperatedTextRuleEntry extends TextRuleEntry { 69 | getRuleValue(){ 70 | const rawText = this.shadowRoot.querySelector("#rule-value-input").value; 71 | return rawText.split(",").map(part => part.trim()); 72 | } 73 | setRuleValue(newRuleValue){ 74 | return this.shadowRoot.querySelector("#rule-value-input").value = newRuleValue.join(", "); 75 | } 76 | } 77 | 78 | class DownloadedAsRuleEntry extends CommaSeperatedTextRuleEntry { 79 | static getRuleLabel(){ 80 | return "downloaded as"; 81 | } 82 | } 83 | customElements.define("downloaded-as-rule-entry", DownloadedAsRuleEntry); 84 | ruleEntryClasses.downloadAs = "downloaded-as-rule-entry"; 85 | 86 | class UserNameRuleEntry extends CommaSeperatedTextRuleEntry { 87 | static getRuleLabel(){ 88 | return "username"; 89 | } 90 | } 91 | customElements.define("username-rule-entry", UserNameRuleEntry); 92 | ruleEntryClasses.username = "username-rule-entry"; 93 | 94 | class BaseDirectoryRuleEntry extends CommaSeperatedTextRuleEntry { 95 | static getRuleLabel(){ 96 | return "base directory"; 97 | } 98 | } 99 | customElements.define("basedirectory-rule-entry", BaseDirectoryRuleEntry); 100 | ruleEntryClasses.baseDirectory = "basedirectory-rule-entry"; 101 | 102 | class FolderPathRuleEntry extends CommaSeperatedTextRuleEntry { 103 | static getRuleLabel(){ 104 | return "folder path"; 105 | } 106 | } 107 | customElements.define("folder-path-as-rule-entry", FolderPathRuleEntry); 108 | ruleEntryClasses.folderPath = "folder-path-as-rule-entry"; 109 | 110 | class RuleElement extends HTMLElement { 111 | constructor(){ 112 | super(); 113 | this.attachShadow({ mode: "open" }); 114 | this.shadowRoot.innerHTML = ` 115 | 131 |
132 |
133 | 144 |
145 | `; 146 | 147 | const addRuleEntryButton = this.shadowRoot.querySelector("#add-rule-entry-button"); 148 | addRuleEntryButton.addEventListener("change", () => { 149 | const selectedOption = addRuleEntryButton.selectedOptions[0]; 150 | const selectedTagName = ruleEntryClasses[selectedOption.getAttribute("data-rule-key")]; 151 | const rulesContainer = this.shadowRoot.querySelector("#rules-container"); 152 | if (this.hasRuleEntryOfType(selectedTagName)){ 153 | return; 154 | } 155 | const ruleEntryElement = document.createElement(selectedTagName); 156 | rulesContainer.appendChild(ruleEntryElement); 157 | saveRules(); 158 | }); 159 | } 160 | addRuleEntry(entryElement){ 161 | this.shadowRoot.querySelector("#rules-container").appendChild(entryElement); 162 | } 163 | hasRuleEntryOfType(tagName){ 164 | const rulesContainer = this.shadowRoot.querySelector("#rules-container"); 165 | return Array.from(rulesContainer.children).some(child => child.tagName.toLowerCase() === tagName); 166 | } 167 | getRuleData(){ 168 | const rulesContainer = this.shadowRoot.querySelector("#rules-container"); 169 | const ruleEntries = Array.from(rulesContainer.children); 170 | const ruleData = {}; 171 | for (let entry of ruleEntries){ 172 | ruleData[getRuleEntryKeyByTagName(entry.tagName.toLowerCase())] = entry.getRuleValue(); 173 | } 174 | return ruleData; 175 | } 176 | } 177 | customElements.define("rule-element", RuleElement); 178 | 179 | document.querySelector("#add-rule-button").addEventListener("click", () => { 180 | const ruleElement = document.createElement("rule-element"); 181 | document.querySelector("#rule-list").appendChild(ruleElement); 182 | }); 183 | 184 | function saveRules(){ 185 | const ruleElements = Array.from(document.querySelector("#rule-list").children); 186 | const rulesData = ruleElements.map(ruleElement => ruleElement.getRuleData()); 187 | sync.set( 188 | { directoryRules: rulesData }, 189 | () => console.log("saved") 190 | ) 191 | } 192 | 193 | function restoreOptions() { 194 | sync.get( 195 | { 196 | baseDownloadDirectory: "", 197 | downloadMethod: "chrome-background", 198 | directoryRules: [] 199 | }, 200 | (items) => { 201 | document.querySelector("#download-directory-input").value = items.baseDownloadDirectory; 202 | 203 | document.querySelector("#download-method").selectedIndex = [ 204 | "native", 205 | "chrome-background" 206 | ].indexOf(items.downloadMethod); 207 | 208 | const ruleList = document.querySelector("#rule-list"); 209 | for (let rule of items.directoryRules){ 210 | const ruleElement = document.createElement("rule-element"); 211 | ruleList.appendChild(ruleElement); 212 | 213 | for (let ruleKey of Reflect.ownKeys(rule)){ 214 | const entryClass = ruleEntryClasses[ruleKey]; 215 | const entryElement = document.createElement(entryClass); 216 | entryElement.setRuleValue(rule[ruleKey]); 217 | ruleElement.addRuleEntry(entryElement); 218 | } 219 | } 220 | } 221 | ); 222 | } 223 | document.addEventListener('DOMContentLoaded', restoreOptions); 224 | 225 | document.querySelector("#download-method").addEventListener("input", (e) => { 226 | const selectEl = e.srcElement; 227 | const selectedMethod = selectEl[selectEl.selectedIndex].value; 228 | sync.set( 229 | { 230 | downloadMethod: selectedMethod 231 | }, 232 | () => console.log("saved") 233 | ) 234 | }); 235 | 236 | //baseDirectory ### 237 | document.querySelector("#download-directory-input").addEventListener("change", () => { 238 | sync.set( 239 | { baseDownloadDirectory: document.querySelector("#download-directory-input").value }, 240 | () => console.log("saved") 241 | ) 242 | }); -------------------------------------------------------------------------------- /app/src/story-extension/linkify-stories.ts: -------------------------------------------------------------------------------- 1 |  2 | 3 | const insertLinkIntoStoryNode = (storyNode: Element) => { 4 | const nameSpans = Array.from(storyNode.querySelectorAll("span")).filter(span => !span.hasAttribute("role")); 5 | if (nameSpans.length > 0){ 6 | const nameSpan = nameSpans[0]; 7 | const name = nameSpan.innerText; 8 | nameSpan.insertAdjacentHTML("afterend", ` 9 | 13 | ${name} 14 | ` 15 | ); 16 | nameSpan.remove(); 17 | } 18 | }; 19 | 20 | const getStoryElementFromCanvas = (canvas: HTMLCanvasElement): Element => { 21 | let parent: Element = canvas; 22 | for (let a = 0; a < 1000; a++){ 23 | if (parent.matches("button")){ 24 | return parent.parentElement; 25 | } 26 | parent = parent.parentElement; 27 | } 28 | }; 29 | 30 | const observer = new MutationObserver(mutations => { 31 | for (const mutation of mutations){ 32 | for (const addedNode of mutation.addedNodes){ 33 | if (!(addedNode as any).querySelector) continue; 34 | const storyCanvas = (addedNode as Element).querySelector("button canvas"); 35 | if (storyCanvas){ 36 | insertLinkIntoStoryNode(getStoryElementFromCanvas(storyCanvas as HTMLCanvasElement)); 37 | } 38 | } 39 | } 40 | }); 41 | observer.observe(document, { childList: true, subtree: true }); 42 | 43 | //initial injection 44 | Array.from(document.querySelectorAll("button canvas")) 45 | .map(getStoryElementFromCanvas) 46 | .forEach(insertLinkIntoStoryNode); -------------------------------------------------------------------------------- /app/src/story-extension/story-scroll-persistence.ts: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | this module persists the scroll-position of the story-scrollbar. 4 | normally, when a story is closed, you return to the main-feed and 5 | need to scroll down again. 6 | */ 7 | 8 | 9 | import { findStoryElement } from '../data-extraction/directly-in-browser/stories/main-element'; 10 | import { pageType, subscribeToNavigation, getCurrentPageType } from '../insta-navigation-observer'; 11 | 12 | 13 | let scrollElement: Element = null; 14 | let currentScrollTop: number = 0; 15 | let scrollListener = null; 16 | 17 | if (getCurrentPageType() === "mainFeed"){ 18 | scrollElement = findStoryElement(); 19 | currentScrollTop = scrollElement.scrollTop; 20 | scrollListener = e => currentScrollTop = scrollElement.scrollTop; 21 | scrollElement.addEventListener("scroll", scrollListener); 22 | } 23 | 24 | 25 | subscribeToNavigation(data => { 26 | if (data.oldPageType === pageType.mainFeed && data.newPageType === pageType.stories) { 27 | onStoryStarted(); 28 | } 29 | else if (data.oldPageType === pageType.stories && data.newPageType === pageType.mainFeed) { 30 | onMainFeedResumed(); 31 | } 32 | }); 33 | 34 | const onStoryStarted = () => { 35 | scrollElement.removeEventListener("scroll", scrollListener); 36 | scrollElement = null; 37 | }; 38 | const onMainFeedResumed = () => { 39 | waitingForStories = true; 40 | }; 41 | 42 | 43 | let waitingForStories = false; 44 | const onRootMutation = () => { 45 | if (!waitingForStories) return; 46 | 47 | scrollElement = findStoryElement(); 48 | if (!scrollElement){ 49 | return; 50 | } 51 | waitingForStories = false; 52 | scrollElement.scrollTop = currentScrollTop; 53 | scrollListener = e => currentScrollTop = scrollElement.scrollTop; 54 | scrollElement.addEventListener("scroll", scrollListener); 55 | }; 56 | const mutationObserver = new MutationObserver(onRootMutation); 57 | mutationObserver.observe( 58 | document.querySelector("#react-root"), 59 | { childList: true, subtree: true } 60 | ); -------------------------------------------------------------------------------- /app/src/video-request-detection.ts: -------------------------------------------------------------------------------- 1 | import { tabs, webRequest } from "webextension-polyfill"; 2 | 3 | console.log("listening for instagram videos ..."); 4 | 5 | // listen for video requests in the background 6 | 7 | webRequest.onBeforeRequest.addListener( 8 | (details) => { 9 | const { url } = details; 10 | if (!url.includes("instagram") && !url.includes(".webm")) return; 11 | 12 | const { tabId } = details; 13 | const urlWithoutByteParams = /.*(?=&bytestart)/.exec(url); 14 | if (urlWithoutByteParams !== null) { 15 | tabs.sendMessage( 16 | tabId, 17 | { url: urlWithoutByteParams[0] } 18 | ); 19 | } 20 | return { cancel: false }; 21 | }, 22 | { urls: ["*://*.fbcdn.net/*"] } 23 | ); -------------------------------------------------------------------------------- /demo/download-button-on-main-feed.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/demo/download-button-on-main-feed.jpg -------------------------------------------------------------------------------- /demo/insta-loader-demo-1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/demo/insta-loader-demo-1.gif -------------------------------------------------------------------------------- /demo/install.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/demo/install.gif -------------------------------------------------------------------------------- /demo/install.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/demo/install.mp4 -------------------------------------------------------------------------------- /demo/mainfeed-download.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/demo/mainfeed-download.gif -------------------------------------------------------------------------------- /demo/mainfeed-download.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/demo/mainfeed-download.mp4 -------------------------------------------------------------------------------- /demo/story-download.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/demo/story-download.gif -------------------------------------------------------------------------------- /demo/story-download.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/demo/story-download.mp4 -------------------------------------------------------------------------------- /demo/uninstall.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/demo/uninstall.gif -------------------------------------------------------------------------------- /demo/uninstall.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/demo/uninstall.mp4 -------------------------------------------------------------------------------- /demo/userpage-download.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/demo/userpage-download.gif -------------------------------------------------------------------------------- /demo/userpage-download.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/demo/userpage-download.mp4 -------------------------------------------------------------------------------- /dev/fiddling/dash-manifest-test.ts: -------------------------------------------------------------------------------- 1 | import { parseDashManifestAndExtractData } from "../../app/src/data-extraction/from-fetch-response/video-dash-manifest"; 2 | 3 | const manifestRaw = ` 4 | 10 | 11 | 21 | 30 | 31 | https://instagram.fscn1-1.fna.fbcdn.net/v/t50.2886-16/276162967_374809297836639_2514563914106085205_n.mp4?_nc_ht=instagram.fscn1-1.fna.fbcdn.net&_nc_cat=103&_nc_ohc=qPMUR5YBGU4AX-2gbpT&edm=AABBvjUBAAAA&ccb=7-4&oh=00_AT-L240eeUnsj2wHyP1Tk2oxog0P9XwRNMZMLrCyRkheTw&oe=6244CF1C&_nc_sid=83d603 32 | 33 | 39 | 40 | 41 | 42 | 43 | 46 | 47 | https://instagram.fscn1-1.fna.fbcdn.net/v/t50.2886-16/277022730_1120918275396595_6748984593543812159_n.mp4?_nc_ht=instagram.fscn1-1.fna.fbcdn.net&_nc_cat=107&_nc_ohc=QcZqs5sdi00AX8DIqZ1&tn=FEu1wceyrb341Q34&edm=AABBvjUBAAAA&ccb=7-4&oh=00_AT9ANE0Fs23Fr1X4ic3FRBKkti4rfyLhwFF7UY95VGJlZg&oe=6244BB79&_nc_sid=83d603 48 | 49 | 52 | 53 | 54 | 55 | 56 | 59 | 60 | https://instagram.fscn1-1.fna.fbcdn.net/v/t50.2886-16/277159045_360091492657151_8777244556821270025_n.mp4?_nc_ht=instagram.fscn1-1.fna.fbcdn.net&_nc_cat=111&_nc_ohc=jSpCg0FC6uMAX9Ddpc0&edm=AABBvjUBAAAA&ccb=7-4&oh=00_AT9m2iQRs2TH1mDDHwb5w3ez5q9_sPFZPT-fftzTZJzi2Q&oe=62445D81&_nc_sid=83d603 61 | 62 | 65 | 66 | 67 | 68 | 69 | 72 | 73 | https://instagram.fscn1-1.fna.fbcdn.net/v/t50.2886-16/277259501_392604682698100_2526476330089834038_n.mp4?_nc_ht=instagram.fscn1-1.fna.fbcdn.net&_nc_cat=104&_nc_ohc=38Cfn0LGcUcAX8OkQaH&tn=FEu1wceyrb341Q34&edm=AABBvjUBAAAA&ccb=7-4&oh=00_AT_cYRd9JcaXNxK6BKwJ97AKarPqQcDMf_njgiV3ZoH7KA&oe=62443D94&_nc_sid=83d603 74 | 75 | 78 | 79 | 80 | 81 | 82 | 85 | 86 | https://instagram.fscn1-1.fna.fbcdn.net/v/t50.2886-16/277020302_311938444191052_3706711486853156796_n.mp4?_nc_ht=instagram.fscn1-1.fna.fbcdn.net&_nc_cat=104&_nc_ohc=CuyNPMSEh8MAX_U5QLa&edm=AABBvjUBAAAA&ccb=7-4&oh=00_AT-vnOgyuK-Gp9KblZJDSULZcWmHNWApxxHm3Ze9Brx2QQ&oe=62449CA0&_nc_sid=83d603 87 | 88 | 91 | 92 | 93 | 94 | 95 | 98 | 99 | https://instagram.fscn1-1.fna.fbcdn.net/v/t50.2886-16/277056353_496554871975308_6901825775329868646_n.mp4?_nc_ht=instagram.fscn1-1.fna.fbcdn.net&_nc_cat=100&_nc_ohc=il6NX06R31EAX8-ZMQc&edm=AABBvjUBAAAA&ccb=7-4&oh=00_AT8ftsemC1Yu_NMnJg_VhxM2kh2LEXBsR3J1GDIUvEwUYA&oe=6244B35B&_nc_sid=83d603 100 | 101 | 104 | 105 | 106 | 107 | 108 | 109 | 112 | 115 | 118 | 119 | https://instagram.fscn1-1.fna.fbcdn.net/v/t50.2886-16/277196416_1031375327470700_1980110613022566934_n.mp4?_nc_ht=instagram.fscn1-1.fna.fbcdn.net&_nc_cat=108&_nc_ohc=Uz6l-3DtO-wAX_m7mN0&edm=AABBvjUBAAAA&ccb=7-4&oh=00_AT-o0jmazVwc9Yxv5db-hq_okjWXvMWdEswWoDlUPfDMwA&oe=62447347&_nc_sid=83d603 120 | 121 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | `; 131 | 132 | console.log( 133 | parseDashManifestAndExtractData(manifestRaw) 134 | ); -------------------------------------------------------------------------------- /dev/fiddling/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /dev/fiddling/index.ts: -------------------------------------------------------------------------------- 1 | import './dash-manifest-test'; -------------------------------------------------------------------------------- /dev/icons/36b3ee2d91ed.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dev/icons/36b3ee2d91ed.ico -------------------------------------------------------------------------------- /dev/icons/7bb4a992fbd2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dev/icons/7bb4a992fbd2.png -------------------------------------------------------------------------------- /dev/icons/credit.txt: -------------------------------------------------------------------------------- 1 |
Icons made by Freepik from www.flaticon.com is licensed by CC 3.0 BY
2 |
Icons made by Freepik from www.flaticon.com is licensed by CC 3.0 BY
3 |
Icons made by Dave Gandy from www.flaticon.com is licensed by CC 3.0 BY
4 |
Icons made by GraphicsBay from www.flaticon.com is licensed by CC 3.0 BY
5 |
Icons made by Chanut from www.flaticon.com is licensed by CC 3.0 BY
6 | 7 | Extern Icons erstellt von Dave Gandy - Flaticon -------------------------------------------------------------------------------- /dev/icons/download-arrows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dev/icons/download-arrows.png -------------------------------------------------------------------------------- /dev/icons/instagram icon 192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dev/icons/instagram icon 192x192.png -------------------------------------------------------------------------------- /dev/icons/instagram icon 32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dev/icons/instagram icon 32x32.png -------------------------------------------------------------------------------- /dev/ideas.txt: -------------------------------------------------------------------------------- 1 | 2 | todo ### 3 | 4 | - shortcut for disk-download 5 | - implement chrome.download 6 | - show if an image is already saved 7 | - button to open folder of model 8 | - button to jump to saved image 9 | - show download queue in the corner and how many where sucessfull/failed. 10 | maybe just showing the queue and how many failed is enough. also persist the failed links 11 | so they are shown again after reload. when there are multiple windows of instagram, there should 12 | be only one queue-instance. 13 | - progress-bar on profile-page when scrolling down 14 | - download entire profile - button 15 | - download entire story - button 16 | - story mode: always pause at start - option, pause when downloading - option 17 | - story mode: open tagged persons in new tab 18 | - auto-download mode where every post/story is downloaded automatically as long as chrome is running 19 | - automatic testing if everything still works, make a page where every step is visible and described in detail, 20 | for example show a feed and pick a random item, then detect the button-bar and highlight it 21 | - sort stories by relevance 22 | - profile page: sometimes there are too many pictures to download in one sitting, 23 | so remember the last seen post and continue later. 24 | - options: enable prompt download, enable native download, only show icons on hover 25 | - include posts of public profiles that you are not following in the feed (to prevent getting banned) 26 | - create a new view for fast story watching. maybe it could look like a grid with about 20 items in the 27 | viewport and i can quickly decide which ones i want to download. it could have like rows where each row 28 | is the story of a person. 29 | - automatic expansion of collections 30 | 31 | 32 | bugs: ### 33 | 34 | - scrolling of story-bar is now janky, maybe because of the mutation-observers for 35 | persistence and navigation 36 | - bug: when dark-reader is enabled, the download-icons are not visible, except the success one 37 | - bug: in a collection when an item is downloaded, the success icon appears, then the next-arrow 38 | is clicked and the next slide is shown which might not be downloaded, but the success icon remains. 39 | this happens because the icons are not updated when another slide is shown. 40 | the same happens when a post is opened in an overlay, and then the "next"-button is pressed 41 | - issue: instagram profiles can change their name and still be the same url. in that case, 42 | there can be two download-folders for the same profile. is this good or bad behaviour? -------------------------------------------------------------------------------- /dev/reference-data/feed-video-1/source.txt: -------------------------------------------------------------------------------- 1 | https://www.instagram.com/p/m3rFkPqsLA/ -------------------------------------------------------------------------------- /dev/reference-data/image-1/source.txt: -------------------------------------------------------------------------------- 1 | https://www.instagram.com/p/CacqjITvavQ/ -------------------------------------------------------------------------------- /dev/reference-data/image-carousel-1/source.txt: -------------------------------------------------------------------------------- 1 | https://www.instagram.com/p/CbrlQr_vugV/ -------------------------------------------------------------------------------- /dev/reference-data/mixed-carousel-1/source.txt: -------------------------------------------------------------------------------- 1 | https://www.instagram.com/p/CViYIIRlZQD/ -------------------------------------------------------------------------------- /dev/reference-data/reel-video-1/source.txt: -------------------------------------------------------------------------------- 1 | https://www.instagram.com/reel/CbsRsq5jfU8/ -------------------------------------------------------------------------------- /dev/reference-data/sources.txt: -------------------------------------------------------------------------------- 1 | 2 | carousel with 3 videos: 3 | https://www.instagram.com/p/Cbcy9-boMZ8/ 4 | 5 | carousel with 3 images: 6 | https://www.instagram.com/p/CbrlQr_vugV/ 7 | 8 | carousel with 1 image and 1 video: 9 | https://www.instagram.com/p/CViYIIRlZQD/ 10 | 11 | video: 12 | https://www.instagram.com/p/CbpjdfJgcS7/ 13 | 14 | video: 15 | https://www.instagram.com/p/Cbrt9XFKKf3/ 16 | 17 | image: 18 | https://www.instagram.com/p/CacqjITvavQ/ 19 | -------------------------------------------------------------------------------- /dev/reference-data/tv-video-1/source.txt: -------------------------------------------------------------------------------- 1 | https://www.instagram.com/tv/CYotDdwDWX3/ -------------------------------------------------------------------------------- /dev/reference-data/video-1/source.txt: -------------------------------------------------------------------------------- 1 | https://www.instagram.com/p/CbpjdfJgcS7/ -------------------------------------------------------------------------------- /dev/reference-data/video-2/source.txt: -------------------------------------------------------------------------------- 1 | https://www.instagram.com/p/Cbh9lvTDJyr/ -------------------------------------------------------------------------------- /dev/reference-data/video-carousel-1/dash-manifest-1.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | https://instagram.fscn1-1.fna.fbcdn.net/v/t50.2886-16/276162967_374809297836639_2514563914106085205_n.mp4?_nc_ht=instagram.fscn1-1.fna.fbcdn.net&_nc_cat=103&_nc_ohc=qPMUR5YBGU4AX-2gbpT&edm=AABBvjUBAAAA&ccb=7-4&oh=00_AT-L240eeUnsj2wHyP1Tk2oxog0P9XwRNMZMLrCyRkheTw&oe=6244CF1C&_nc_sid=83d603 6 | 7 | 8 | 9 | 10 | 11 | https://instagram.fscn1-1.fna.fbcdn.net/v/t50.2886-16/277022730_1120918275396595_6748984593543812159_n.mp4?_nc_ht=instagram.fscn1-1.fna.fbcdn.net&_nc_cat=107&_nc_ohc=QcZqs5sdi00AX8DIqZ1&tn=FEu1wceyrb341Q34&edm=AABBvjUBAAAA&ccb=7-4&oh=00_AT9ANE0Fs23Fr1X4ic3FRBKkti4rfyLhwFF7UY95VGJlZg&oe=6244BB79&_nc_sid=83d603 12 | 13 | 14 | 15 | 16 | 17 | https://instagram.fscn1-1.fna.fbcdn.net/v/t50.2886-16/277159045_360091492657151_8777244556821270025_n.mp4?_nc_ht=instagram.fscn1-1.fna.fbcdn.net&_nc_cat=111&_nc_ohc=jSpCg0FC6uMAX9Ddpc0&edm=AABBvjUBAAAA&ccb=7-4&oh=00_AT9m2iQRs2TH1mDDHwb5w3ez5q9_sPFZPT-fftzTZJzi2Q&oe=62445D81&_nc_sid=83d603 18 | 19 | 20 | 21 | 22 | 23 | https://instagram.fscn1-1.fna.fbcdn.net/v/t50.2886-16/277259501_392604682698100_2526476330089834038_n.mp4?_nc_ht=instagram.fscn1-1.fna.fbcdn.net&_nc_cat=104&_nc_ohc=38Cfn0LGcUcAX8OkQaH&tn=FEu1wceyrb341Q34&edm=AABBvjUBAAAA&ccb=7-4&oh=00_AT_cYRd9JcaXNxK6BKwJ97AKarPqQcDMf_njgiV3ZoH7KA&oe=62443D94&_nc_sid=83d603 24 | 25 | 26 | 27 | 28 | 29 | https://instagram.fscn1-1.fna.fbcdn.net/v/t50.2886-16/277020302_311938444191052_3706711486853156796_n.mp4?_nc_ht=instagram.fscn1-1.fna.fbcdn.net&_nc_cat=104&_nc_ohc=CuyNPMSEh8MAX_U5QLa&edm=AABBvjUBAAAA&ccb=7-4&oh=00_AT-vnOgyuK-Gp9KblZJDSULZcWmHNWApxxHm3Ze9Brx2QQ&oe=62449CA0&_nc_sid=83d603 30 | 31 | 32 | 33 | 34 | 35 | https://instagram.fscn1-1.fna.fbcdn.net/v/t50.2886-16/277056353_496554871975308_6901825775329868646_n.mp4?_nc_ht=instagram.fscn1-1.fna.fbcdn.net&_nc_cat=100&_nc_ohc=il6NX06R31EAX8-ZMQc&edm=AABBvjUBAAAA&ccb=7-4&oh=00_AT8ftsemC1Yu_NMnJg_VhxM2kh2LEXBsR3J1GDIUvEwUYA&oe=6244B35B&_nc_sid=83d603 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | https://instagram.fscn1-1.fna.fbcdn.net/v/t50.2886-16/277196416_1031375327470700_1980110613022566934_n.mp4?_nc_ht=instagram.fscn1-1.fna.fbcdn.net&_nc_cat=108&_nc_ohc=Uz6l-3DtO-wAX_m7mN0&edm=AABBvjUBAAAA&ccb=7-4&oh=00_AT-o0jmazVwc9Yxv5db-hq_okjWXvMWdEswWoDlUPfDMwA&oe=62447347&_nc_sid=83d603 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /dev/reference-data/video-carousel-1/source.txt: -------------------------------------------------------------------------------- 1 | https://www.instagram.com/p/Cbcy9-boMZ8/ -------------------------------------------------------------------------------- /dist/assets/background.ts.836b27b6.js: -------------------------------------------------------------------------------- 1 | import{b as r}from"./vendor.b289aac1.js";async function d(e,t){try{const s=await r.exports.downloads.download({url:t.url,filename:t.filePath,conflictAction:"prompt"});e.postMessage({type:"download-id",id:s})}catch(s){e.postMessage({type:"error",error:s})}}async function l(e,t){const s=await r.exports.downloads.search({id:t.id});if(s.length===0){e.postMessage({type:"error",error:"download started but file not found. this may be a problem with the browser."});return}s.length>1&&console.warn("more than one file for this download found. this should not happen");const o=s[0],n=o.state;if(n==="interrupted"){e.postMessage({type:"error",error:o.error});return}if(n==="complete"){e.postMessage({type:"success"});return}if(n==="in_progress"){e.postMessage({type:"progress",progress:{bytesReceived:o.bytesReceived,totalBytes:o.totalBytes,progress:o.bytesReceived/o.totalBytes}});return}}r.exports.runtime.onConnect.addListener(e=>{e.name==="chrome-downloader"&&(e.onDisconnect.addListener(()=>console.log("port disconnected")),e.onMessage.addListener(async(t,s)=>{if(console.log(t),t.type==="request-download"){d(e,t);return}t.type==="request-state"&&l(e,t)}))});console.log("listening for instagram API calls ...");function c(e){const t={};for(const{name:s,value:o}of e)o!==void 0&&(t[s]=o);return t}function u(e){const{tabId:t,url:s}=e,o=/(?<=instagram\.com\/api\/v1\/media\/)\d*(?=\/info)/.exec(s);if(!o)return;const n=o[0];r.exports.tabs.sendMessage(t,{mediaID:n})}r.exports.webRequest.onSendHeaders.addListener(u,{urls:["*://i.instagram.com/api/*","*://www.instagram.com/api/*"]},["requestHeaders"]);const a="PolarisPostRootQuery",i=["*://www.instagram.com/api/graphql"];r.exports.webRequest.onSendHeaders.addListener(e=>{const{requestHeaders:t}=e;if(!t)return;console.log("graphql request headers",e);const s=t.find(({name:o})=>o==="X-FB-Friendly-Name");!s||s.value===a&&r.exports.tabs.sendMessage(e.tabId,{requestHeaders:c(t)})},{urls:i},["requestHeaders"]);r.exports.webRequest.onBeforeRequest.addListener(e=>{console.log("graphql request body",e);const t=e.requestBody?.formData;if(!t)return;const{fb_api_req_friendly_name:s}=t;!s||s[0]===a&&r.exports.tabs.sendMessage(e.tabId,{requestBody:t})},{urls:i},["requestBody"]);r.exports.webNavigation.onHistoryStateUpdated.addListener(e=>{console.log("waking up")}); 2 | -------------------------------------------------------------------------------- /dist/assets/content-script-loader.index.ts.3e506a8d.037ffce3.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | 'use strict'; 3 | 4 | (async () => { 5 | await import( 6 | /* @vite-ignore */ 7 | chrome.runtime.getURL("assets/index.ts.3e506a8d.js") 8 | ); 9 | })().catch(console.error); 10 | 11 | })(); 12 | -------------------------------------------------------------------------------- /dist/assets/icons/download-icon-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dist/assets/icons/download-icon-black.png -------------------------------------------------------------------------------- /dist/assets/icons/download-icon-dark-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dist/assets/icons/download-icon-dark-3.png -------------------------------------------------------------------------------- /dist/assets/icons/download-icon-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dist/assets/icons/download-icon-dark.png -------------------------------------------------------------------------------- /dist/assets/icons/download-icon-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dist/assets/icons/download-icon-white.png -------------------------------------------------------------------------------- /dist/assets/icons/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dist/assets/icons/error.png -------------------------------------------------------------------------------- /dist/assets/icons/external-link-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dist/assets/icons/external-link-black.png -------------------------------------------------------------------------------- /dist/assets/icons/external-link-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dist/assets/icons/external-link-white.png -------------------------------------------------------------------------------- /dist/assets/icons/insta-loader-icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dist/assets/icons/insta-loader-icon-128.png -------------------------------------------------------------------------------- /dist/assets/icons/insta-loader-icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dist/assets/icons/insta-loader-icon-16.png -------------------------------------------------------------------------------- /dist/assets/icons/insta-loader-icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dist/assets/icons/insta-loader-icon-192.png -------------------------------------------------------------------------------- /dist/assets/icons/insta-loader-icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dist/assets/icons/insta-loader-icon-48.png -------------------------------------------------------------------------------- /dist/assets/icons/save-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dist/assets/icons/save-dark.png -------------------------------------------------------------------------------- /dist/assets/icons/save-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dist/assets/icons/save-white.png -------------------------------------------------------------------------------- /dist/assets/icons/save.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dist/assets/icons/save.png -------------------------------------------------------------------------------- /dist/assets/icons/spinner-of-dots dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dist/assets/icons/spinner-of-dots dark.png -------------------------------------------------------------------------------- /dist/assets/icons/spinner-of-dots white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dist/assets/icons/spinner-of-dots white.png -------------------------------------------------------------------------------- /dist/assets/icons/spinner-of-dots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dist/assets/icons/spinner-of-dots.png -------------------------------------------------------------------------------- /dist/assets/icons/verify-sign black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dist/assets/icons/verify-sign black.png -------------------------------------------------------------------------------- /dist/assets/icons/verify-sign dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dist/assets/icons/verify-sign dark.png -------------------------------------------------------------------------------- /dist/assets/icons/verify-sign-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/dist/assets/icons/verify-sign-green.png -------------------------------------------------------------------------------- /dist/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "insta-loader", 4 | "description": "download buttons for instagram media.", 5 | "version": "1.3.29", 6 | "icons": { 7 | "16": "assets/icons/insta-loader-icon-16.png", 8 | "48": "assets/icons/insta-loader-icon-48.png", 9 | "128": "assets/icons/insta-loader-icon-128.png" 10 | }, 11 | "background": { 12 | "service_worker": "service-worker-loader.js", 13 | "type": "module" 14 | }, 15 | "content_scripts": [ 16 | { 17 | "js": [ 18 | "assets/content-script-loader.index.ts.3e506a8d.037ffce3.js" 19 | ], 20 | "run_at": "document_start", 21 | "matches": [ 22 | "*://www.instagram.com/*" 23 | ] 24 | } 25 | ], 26 | "permissions": [ 27 | "storage", 28 | "nativeMessaging", 29 | "tabs", 30 | "notifications", 31 | "downloads", 32 | "webRequest", 33 | "webNavigation" 34 | ], 35 | "host_permissions": [ 36 | "*://www.instagram.com/*", 37 | "*://i.instagram.com/api/v1/media/*", 38 | "*://www.instagram.com/api/v1/media/*", 39 | "*://www.instagram.com/api/*" 40 | ], 41 | "web_accessible_resources": [ 42 | { 43 | "matches": [ 44 | "*://www.instagram.com/*" 45 | ], 46 | "resources": [ 47 | "assets/icons/*.png" 48 | ], 49 | "use_dynamic_url": false 50 | }, 51 | { 52 | "matches": [ 53 | "*://www.instagram.com/*" 54 | ], 55 | "resources": [ 56 | "assets/vendor.b289aac1.js", 57 | "assets/index.ts.3e506a8d.js" 58 | ], 59 | "use_dynamic_url": false 60 | } 61 | ] 62 | } -------------------------------------------------------------------------------- /dist/service-worker-loader.js: -------------------------------------------------------------------------------- 1 | import './assets/background.ts.836b27b6.js'; 2 | -------------------------------------------------------------------------------- /host/checklist for native messaging.txt: -------------------------------------------------------------------------------- 1 | - don't use the print- function in the python script, it messes with the stdio 2 | - in the manifest, an allowed_origin must have a trailing slash, like: 3 | "chrome-extension://ibbhlbcdnfejhiobajmdbepmnmoifgab/", 4 | not "chrome-extension://ibbhlbcdnfejhiobajmdbepmnmoifgab" 5 | - python scripts must have execute-permission, this can be done via "chmod a+rx [script_path]" 6 | -------------------------------------------------------------------------------- /host/linux_mac/insta_loader_host.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "insta_loader_host", 3 | "description": "host for filesystem operations", 4 | "path": "", 5 | "type": "stdio", 6 | "allowed_origins": [] 7 | } 8 | -------------------------------------------------------------------------------- /host/linux_mac/insta_loader_host.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import struct 3 | import sys 4 | import os 5 | import urllib 6 | import ast 7 | import json 8 | 9 | import requests 10 | import math 11 | 12 | # functions: 13 | # - path exist 14 | # - download file to path 15 | 16 | # never use print when calling from chrome. the print call will taint the stdout stream 17 | 18 | # usage: 19 | # pass a stringified json into this script, the json must look like this: 20 | # { 21 | # "requests": [ 22 | # { 23 | # "action": "check path existence" || "write media by link", 24 | # "data": "some/path/to/check" || { 25 | # "folderPath": "/some/path/to/folder" 26 | # "fileName": "randomFileName.jpg", 27 | # "link": "randomMediaLink.jpg" 28 | # } 29 | # } 30 | # ] 31 | # } 32 | # after the requests are handled, the feedback may looks like this: 33 | # { 34 | # "result": [ 35 | # { "type": "error", "data": "wrong key somewhere" }, 36 | # { "type": "success", data: "writtenFilePath.jpg" } 37 | # ] 38 | # } 39 | # [terminal]: python insta_loader_host '{"requests": [{"action": "check path existence", "data": "some/path/file"}]}' 40 | 41 | 42 | # On Windows, the default I/O mode is O_TEXT. Set this to O_BINARY 43 | # to avoid unwanted modifications of the input/output streams. 44 | if sys.platform == "win32": 45 | import os, msvcrt 46 | msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY) 47 | msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY) 48 | 49 | # Helper function that sends a message to the webapp. 50 | def send_message(message): 51 | write_to_debug("send message") 52 | # Write message size. 53 | sys.stdout.write(struct.pack('I', len(message))) 54 | # Write the message itself. 55 | sys.stdout.write(message.encode('utf-8')) 56 | sys.stdout.flush() 57 | 58 | def write_to_debug(message): 59 | file = open('debug file.txt', 'w') 60 | file.write(message) 61 | file.close() 62 | 63 | def download_file_with_callback(url, path, progress_callback): 64 | 65 | # Streaming, so we can iterate over the response. 66 | req = requests.get(url, stream=True) 67 | 68 | # Total size in bytes. 69 | total_size = int(req.headers.get('content-length', 0)); 70 | 71 | block_size = 1024 72 | wrote = 0 73 | with open(path, 'wb') as f: 74 | for data in req.iter_content(block_size): 75 | wrote = wrote + len(data) 76 | progress = float(wrote) / float(total_size) 77 | progress_callback(progress) 78 | f.write(data) 79 | 80 | if wrote != total_size: 81 | raise ValueError('written size does not match total size, total size: ' + str(total_size) + ', written size: ' + str(wrote)) 82 | 83 | 84 | def download_file_and_send_progress(path, url): 85 | def callback(progress): 86 | msg = '[{ "type": "progress", "data": { "progress": ' + str(progress) + ' } }]' 87 | send_message(msg) 88 | # for some reason, python refuses to send a message with the link or url in it, only progress is allowed. 89 | #send_message('[{ "type": "progress", "data": { "link": ' + str(url) + ', "progress": ' + str(progress) + ' } }]') 90 | #send_message('[{ "type": "progress", "data": { "link": ' + url + ', "folderPath": ' + path + ', "progress": ' + str(progress) + ' } }]') 91 | 92 | download_file_with_callback(url, path, callback) 93 | 94 | 95 | def download_file(path, url): 96 | urllib.urlretrieve(url, path) 97 | 98 | 99 | 100 | # Thread that reads messages from the webapp. 101 | def read_thread_func(): 102 | 103 | #write_to_debug("start"); 104 | 105 | executionMode = "chrome extension" 106 | if executionMode == "terminal": 107 | inputText = sys.argv[1] 108 | elif executionMode == "chrome extension": 109 | # Read the message length (first 4 bytes). 110 | text_length_bytes = sys.stdin.read(4) 111 | if len(text_length_bytes) == 0: 112 | sys.exit(0) 113 | # Unpack message length as 4 byte integer. 114 | text_length = struct.unpack('i', text_length_bytes)[0] 115 | # Read the text (JSON object) of the message. 116 | text = sys.stdin.read(text_length).decode('utf-8') 117 | inputText = ast.literal_eval(text) 118 | 119 | # print "input: " + inputText 120 | requestData = json.loads(inputText) 121 | requestArray = requestData["requests"] 122 | #write_to_debug("request is ready"); 123 | 124 | resultArray = [] 125 | for index, request in enumerate(requestArray): 126 | resultObject = {} 127 | 128 | if not 'action' in request: 129 | resultObject['type'] = 'error' 130 | resultObject['data'] = 'wrong action-key' 131 | 132 | elif not 'data' in request: 133 | resultObject['type'] = 'error' 134 | resultObject['data'] = 'wrong data-key' 135 | 136 | else: 137 | action = request['action'] 138 | data = request['data'] 139 | if action == "check path existence": 140 | pathExists = os.path.exists(data) 141 | resultObject['type'] = 'success' 142 | resultObject['data'] = pathExists 143 | 144 | elif action == "write media by link": 145 | if not ('link' in data and 'folderPath' in data and 'fileName' in data): 146 | resultObject['type'] = 'error' 147 | errorMessage = "" 148 | if not 'link' in data: 149 | errorMessage = "wrong link-key" 150 | elif not 'folderPath' in data: 151 | errorMessage = "wrong folderPath-key" 152 | elif not 'fileName' in data: 153 | errorMessage = "wrong fileName-key" 154 | resultObject['data'] = errorMessage 155 | else: 156 | try: 157 | mediaFolderPath = data['folderPath'] 158 | mediaName = data['fileName'] 159 | mediaSrc = data['link'] 160 | #print mediaSrc 161 | 162 | if not os.path.exists(mediaFolderPath): 163 | os.makedirs(mediaFolderPath) 164 | 165 | mediaPath = os.path.join(mediaFolderPath, mediaName) 166 | if not os.path.isfile(mediaPath): 167 | download_file_and_send_progress(mediaPath, mediaSrc) 168 | 169 | resultObject['type'] = 'success' 170 | resultObject['data'] = mediaPath 171 | 172 | except Exception, exceptionObj: 173 | resultObject['type'] = 'error' 174 | resultObject['data'] = str(exceptionObj) 175 | 176 | resultArray.append(resultObject); 177 | 178 | 179 | resultArrayJson = json.dumps(resultArray) 180 | #print resultArrayJson 181 | #write_to_debug("result is ready"); 182 | 183 | result = resultArrayJson 184 | sys.stdout.write(struct.pack('I', len(result))) 185 | #write_to_debug("after write length") 186 | # Write the message itself. 187 | sys.stdout.write(result.encode('utf-8')) 188 | #write_to_debug("after write json") 189 | sys.stdout.flush() 190 | #write_to_debug("after flush") 191 | #write_to_debug(result) 192 | 193 | def Main(): 194 | read_thread_func() 195 | sys.exit(0) 196 | 197 | if __name__ == '__main__': 198 | Main() 199 | -------------------------------------------------------------------------------- /host/linux_mac/insta_loader_host_starter.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | python2.7 insta_loader_host.py -------------------------------------------------------------------------------- /host/windows/insta_loader_host.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "insta_loader_host", 3 | "description": "host for filesystem operations", 4 | "path": "insta_loader_host_starter.bat", 5 | "type": "stdio", 6 | "allowed_origins": [] 7 | } 8 | -------------------------------------------------------------------------------- /host/windows/insta_loader_host.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import struct 3 | import sys 4 | import os 5 | import urllib 6 | import ast 7 | import json 8 | 9 | import requests 10 | import math 11 | 12 | # functions: 13 | # - path exist 14 | # - download file to path 15 | 16 | # never use print when calling from chrome. the print call will taint the stdout stream 17 | 18 | # usage: 19 | # pass a stringified json into this script, the json must look like this: 20 | # { 21 | # "requests": [ 22 | # { 23 | # "action": "check path existence" || "write media by link", 24 | # "data": "some/path/to/check" || { 25 | # "folderPath": "/some/path/to/folder" 26 | # "fileName": "randomFileName.jpg", 27 | # "link": "randomMediaLink.jpg" 28 | # } 29 | # } 30 | # ] 31 | # } 32 | # after the requests are handled, the feedback may looks like this: 33 | # { 34 | # "result": [ 35 | # { "type": "error", "data": "wrong key somewhere" }, 36 | # { "type": "success", data: "writtenFilePath.jpg" } 37 | # ] 38 | # } 39 | # [terminal]: python insta_loader_host '{"requests": [{"action": "check path existence", "data": "some/path/file"}]}' 40 | 41 | 42 | # On Windows, the default I/O mode is O_TEXT. Set this to O_BINARY 43 | # to avoid unwanted modifications of the input/output streams. 44 | if sys.platform == "win32": 45 | import os, msvcrt 46 | msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY) 47 | msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY) 48 | 49 | # Helper function that sends a message to the webapp. 50 | def send_message(message): 51 | write_to_debug("send message") 52 | # Write message size. 53 | sys.stdout.write(struct.pack('I', len(message))) 54 | # Write the message itself. 55 | sys.stdout.write(message.encode('utf-8')) 56 | sys.stdout.flush() 57 | 58 | def write_to_debug(message): 59 | file = open('debug file.txt', 'w') 60 | file.write(message) 61 | file.close() 62 | 63 | def download_file_with_callback(url, path, progress_callback): 64 | 65 | # Streaming, so we can iterate over the response. 66 | req = requests.get(url, stream=True) 67 | 68 | # Total size in bytes. 69 | total_size = int(req.headers.get('content-length', 0)); 70 | 71 | block_size = 1024 72 | wrote = 0 73 | with open(path, 'wb') as f: 74 | for data in req.iter_content(block_size): 75 | wrote = wrote + len(data) 76 | progress = float(wrote) / float(total_size) 77 | progress_callback(progress) 78 | f.write(data) 79 | 80 | if wrote != total_size: 81 | raise ValueError('written size does not match total size, total size: ' + str(total_size) + ', written size: ' + str(wrote)) 82 | 83 | 84 | def download_file_and_send_progress(path, url): 85 | def callback(progress): 86 | msg = '[{ "type": "progress", "data": { "progress": ' + str(progress) + ' } }]' 87 | send_message(msg) 88 | # for some reason, python refuses to send a message with the link or url in it, only progress is allowed. 89 | #send_message('[{ "type": "progress", "data": { "link": ' + str(url) + ', "progress": ' + str(progress) + ' } }]') 90 | #send_message('[{ "type": "progress", "data": { "link": ' + url + ', "folderPath": ' + path + ', "progress": ' + str(progress) + ' } }]') 91 | 92 | download_file_with_callback(url, path, callback) 93 | 94 | 95 | def download_file(path, url): 96 | urllib.urlretrieve(url, path) 97 | 98 | 99 | 100 | # Thread that reads messages from the webapp. 101 | def read_thread_func(): 102 | 103 | #write_to_debug("start"); 104 | 105 | executionMode = "chrome extension" 106 | if executionMode == "terminal": 107 | inputText = sys.argv[1] 108 | elif executionMode == "chrome extension": 109 | # Read the message length (first 4 bytes). 110 | text_length_bytes = sys.stdin.read(4) 111 | if len(text_length_bytes) == 0: 112 | sys.exit(0) 113 | # Unpack message length as 4 byte integer. 114 | text_length = struct.unpack('i', text_length_bytes)[0] 115 | # Read the text (JSON object) of the message. 116 | text = sys.stdin.read(text_length).decode('utf-8') 117 | inputText = ast.literal_eval(text) 118 | 119 | # print "input: " + inputText 120 | requestData = json.loads(inputText) 121 | requestArray = requestData["requests"] 122 | #write_to_debug("request is ready"); 123 | 124 | resultArray = [] 125 | for index, request in enumerate(requestArray): 126 | resultObject = {} 127 | 128 | if not 'action' in request: 129 | resultObject['type'] = 'error' 130 | resultObject['data'] = 'wrong action-key' 131 | 132 | elif not 'data' in request: 133 | resultObject['type'] = 'error' 134 | resultObject['data'] = 'wrong data-key' 135 | 136 | else: 137 | action = request['action'] 138 | data = request['data'] 139 | if action == "check path existence": 140 | pathExists = os.path.exists(data) 141 | resultObject['type'] = 'success' 142 | resultObject['data'] = pathExists 143 | 144 | elif action == "write media by link": 145 | if not ('link' in data and 'folderPath' in data and 'fileName' in data): 146 | resultObject['type'] = 'error' 147 | errorMessage = "" 148 | if not 'link' in data: 149 | errorMessage = "wrong link-key" 150 | elif not 'folderPath' in data: 151 | errorMessage = "wrong folderPath-key" 152 | elif not 'fileName' in data: 153 | errorMessage = "wrong fileName-key" 154 | resultObject['data'] = errorMessage 155 | else: 156 | try: 157 | mediaFolderPath = data['folderPath'] 158 | mediaName = data['fileName'] 159 | mediaSrc = data['link'] 160 | #print mediaSrc 161 | 162 | if not os.path.exists(mediaFolderPath): 163 | os.makedirs(mediaFolderPath) 164 | 165 | mediaPath = os.path.join(mediaFolderPath, mediaName) 166 | if not os.path.isfile(mediaPath): 167 | download_file_and_send_progress(mediaPath, mediaSrc) 168 | 169 | resultObject['type'] = 'success' 170 | resultObject['data'] = mediaPath 171 | 172 | except Exception, exceptionObj: 173 | resultObject['type'] = 'error' 174 | resultObject['data'] = str(exceptionObj) 175 | 176 | resultArray.append(resultObject); 177 | 178 | 179 | resultArrayJson = json.dumps(resultArray) 180 | #print resultArrayJson 181 | #write_to_debug("result is ready"); 182 | 183 | result = resultArrayJson 184 | sys.stdout.write(struct.pack('I', len(result))) 185 | #write_to_debug("after write length") 186 | # Write the message itself. 187 | sys.stdout.write(result.encode('utf-8')) 188 | #write_to_debug("after write json") 189 | sys.stdout.flush() 190 | #write_to_debug("after flush") 191 | #write_to_debug(result) 192 | 193 | def Main(): 194 | read_thread_func() 195 | sys.exit(0) 196 | 197 | if __name__ == '__main__': 198 | Main() 199 | -------------------------------------------------------------------------------- /host/windows/insta_loader_host_starter.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | python "%~dp0/insta_loader_host.py" %* -------------------------------------------------------------------------------- /host/windows/install_host.bat: -------------------------------------------------------------------------------- 1 | :: Copyright 2014 The Chromium Authors. All rights reserved. 2 | :: Use of this source code is governed by a BSD-style license that can be 3 | :: found in the LICENSE file. 4 | 5 | :: Change HKCU to HKLM if you want to install globally. 6 | :: %~dp0 is the directory containing this bat script and ends with a backslash. 7 | REG ADD "HKCU\Software\Google\Chrome\NativeMessagingHosts\insta_loader_host" /ve /t REG_SZ /d "%~dp0insta_loader_host.json" /f 8 | -------------------------------------------------------------------------------- /host/windows/uninstall_host.bat: -------------------------------------------------------------------------------- 1 | :: Copyright 2014 The Chromium Authors. All rights reserved. 2 | :: Use of this source code is governed by a BSD-style license that can be 3 | :: found in the LICENSE file. 4 | 5 | :: Deletes the entry created by install_host.bat 6 | REG DELETE "HKCU\Software\Google\Chrome\NativeMessagingHosts\insta_loader_host" /f 7 | REG DELETE "HKLM\Software\Google\Chrome\NativeMessagingHosts\insta_loader_host" /f 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | 4 | "name": "insta-loader", 5 | "description": "download buttons for instagram media.", 6 | "version": "1.3.29", 7 | 8 | "icons": { 9 | "16": "assets/icons/insta-loader-icon-16.png", 10 | "48": "assets/icons/insta-loader-icon-48.png", 11 | "128": "assets/icons/insta-loader-icon-128.png" 12 | }, 13 | 14 | "background": { 15 | "service_worker": "app/src/background.ts", 16 | "type": "module" 17 | }, 18 | 19 | "content_scripts": [ 20 | { 21 | "run_at": "document_start", 22 | "matches": ["*://www.instagram.com/*"], 23 | "js": ["app/src/index.ts"] 24 | } 25 | ], 26 | 27 | "permissions": [ 28 | "storage", 29 | "nativeMessaging", 30 | "tabs", 31 | "notifications", 32 | "downloads", 33 | "webRequest", 34 | "webNavigation" 35 | ], 36 | 37 | "host_permissions": [ 38 | "*://www.instagram.com/*", 39 | "*://i.instagram.com/api/v1/media/*", 40 | "*://www.instagram.com/api/v1/media/*", 41 | "*://www.instagram.com/api/*" 42 | ], 43 | 44 | "web_accessible_resources": [{ 45 | "resources": [ 46 | "assets/icons/*.png" 47 | ], 48 | "matches": [ 49 | "*://www.instagram.com/*" 50 | ] 51 | }] 52 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flurrux/insta-loader", 3 | "version": "1.0.0", 4 | "description": "download buttons for instagram", 5 | "scripts": { 6 | "test": "jest", 7 | "build": "vite build" 8 | }, 9 | "author": "Christian Hoffmann ", 10 | "dependencies": { 11 | "fp-ts": "^2.9.5" 12 | }, 13 | "devDependencies": { 14 | "@types/chrome": "^0.0.129", 15 | "@types/jest": "^27.4.1", 16 | "jest": "^27.5.1", 17 | "rollup-plugin-chrome-extension": "^4.0.1-16", 18 | "ts-jest": "^27.1.3", 19 | "typescript": "^3.9.10", 20 | "vite": "^2.8.6", 21 | "webextension-polyfill": "^0.10.0", 22 | "@types/webextension-polyfill": "^0.9.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /public/assets/icons/download-icon-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/public/assets/icons/download-icon-black.png -------------------------------------------------------------------------------- /public/assets/icons/download-icon-dark-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/public/assets/icons/download-icon-dark-3.png -------------------------------------------------------------------------------- /public/assets/icons/download-icon-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/public/assets/icons/download-icon-dark.png -------------------------------------------------------------------------------- /public/assets/icons/download-icon-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/public/assets/icons/download-icon-white.png -------------------------------------------------------------------------------- /public/assets/icons/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/public/assets/icons/error.png -------------------------------------------------------------------------------- /public/assets/icons/external-link-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/public/assets/icons/external-link-black.png -------------------------------------------------------------------------------- /public/assets/icons/external-link-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/public/assets/icons/external-link-white.png -------------------------------------------------------------------------------- /public/assets/icons/insta-loader-icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/public/assets/icons/insta-loader-icon-128.png -------------------------------------------------------------------------------- /public/assets/icons/insta-loader-icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/public/assets/icons/insta-loader-icon-16.png -------------------------------------------------------------------------------- /public/assets/icons/insta-loader-icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/public/assets/icons/insta-loader-icon-192.png -------------------------------------------------------------------------------- /public/assets/icons/insta-loader-icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/public/assets/icons/insta-loader-icon-48.png -------------------------------------------------------------------------------- /public/assets/icons/save-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/public/assets/icons/save-dark.png -------------------------------------------------------------------------------- /public/assets/icons/save-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/public/assets/icons/save-white.png -------------------------------------------------------------------------------- /public/assets/icons/save.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/public/assets/icons/save.png -------------------------------------------------------------------------------- /public/assets/icons/spinner-of-dots dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/public/assets/icons/spinner-of-dots dark.png -------------------------------------------------------------------------------- /public/assets/icons/spinner-of-dots white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/public/assets/icons/spinner-of-dots white.png -------------------------------------------------------------------------------- /public/assets/icons/spinner-of-dots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/public/assets/icons/spinner-of-dots.png -------------------------------------------------------------------------------- /public/assets/icons/verify-sign black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/public/assets/icons/verify-sign black.png -------------------------------------------------------------------------------- /public/assets/icons/verify-sign dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/public/assets/icons/verify-sign dark.png -------------------------------------------------------------------------------- /public/assets/icons/verify-sign-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flurrux/insta-loader/de75c9081d9add8be27404f158345678aa3f9076/public/assets/icons/verify-sign-green.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "esModuleInterop": true, 5 | "jsx": "react", 6 | "module": "es2015", 7 | "moduleResolution": "node", 8 | "noImplicitReturns": true, 9 | "noUnusedLocals": true, 10 | "outDir": "./dist", 11 | "skipLibCheck": true, 12 | "sourceMap": true, 13 | "strict": true, 14 | "target": "es2018", 15 | "rootDir": "." 16 | } 17 | } -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { chromeExtension } from "rollup-plugin-chrome-extension"; 3 | import manifest from './manifest.json'; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | chromeExtension({ 8 | manifest 9 | }) 10 | ], 11 | }); --------------------------------------------------------------------------------