├── .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 | 
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 |
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 |
37 | native
38 | chrome-background
39 |
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 | x
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 | ${this.constructor.getRuleLabel()}
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 |
134 | +
135 | ${
136 | Reflect.ownKeys(ruleEntryClasses).map(key => {
137 | return `
138 | ${key}
139 | `;
140 | })
141 | .join("")
142 | }
143 |
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 |
2 |
3 |
4 |
5 |
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 | });
--------------------------------------------------------------------------------