├── public ├── icons │ ├── icon-128.png │ ├── icon-16.png │ ├── icon-32.png │ ├── icon-48.png │ └── icon-96.png ├── popup │ └── index.html └── options │ └── index.html ├── .gitignore ├── .github └── ISSUE_TEMPLATE │ └── bug_report.md ├── src ├── inject.ts ├── content │ ├── utils │ │ ├── dom.ts │ │ ├── zip.ts │ │ └── fn.ts │ ├── threads │ │ ├── button.ts │ │ ├── index.ts │ │ └── post.ts │ ├── profile.ts │ ├── reels.ts │ ├── highlights.ts │ ├── post-detail.ts │ ├── stories.ts │ ├── post.ts │ ├── profile-reel.ts │ ├── button.ts │ └── index.ts ├── constants.ts ├── popup │ ├── SettingItem.tsx │ ├── index.scss │ └── index.tsx ├── options │ ├── index.ts │ └── index.scss ├── manifest.firefox.json ├── manifest.chrome.json ├── types │ ├── profileReel.d.ts │ ├── stories.d.ts │ ├── reels.d.ts │ ├── highlights.d.ts │ └── global.d.ts ├── background │ ├── fn.ts │ ├── chrome.ts │ └── firefox.ts └── xhr.ts ├── tsconfig.json ├── LICENSE ├── eslint.config.js ├── README.md ├── esbuild.config.mjs └── package.json /public/icons/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheKonka/instagram-download-browser-extension/HEAD/public/icons/icon-128.png -------------------------------------------------------------------------------- /public/icons/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheKonka/instagram-download-browser-extension/HEAD/public/icons/icon-16.png -------------------------------------------------------------------------------- /public/icons/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheKonka/instagram-download-browser-extension/HEAD/public/icons/icon-32.png -------------------------------------------------------------------------------- /public/icons/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheKonka/instagram-download-browser-extension/HEAD/public/icons/icon-48.png -------------------------------------------------------------------------------- /public/icons/icon-96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheKonka/instagram-download-browser-extension/HEAD/public/icons/icon-96.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | **/.DS_Store 4 | yarn-error.log 5 | .idea 6 | .vscode 7 | yarn.lock 8 | package-lock.json 9 | 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | Browser(Chrome/Firefox/Firefox for Android): 10 | Extension Version: 11 | Reproduction of links: 12 | -------------------------------------------------------------------------------- /src/inject.ts: -------------------------------------------------------------------------------- 1 | const script = document.createElement('script'); 2 | script.setAttribute('type', 'text/javascript'); 3 | script.setAttribute('src', chrome.runtime.getURL('xhr.js')); 4 | script.onload = () => { 5 | script.remove(); 6 | }; 7 | (document.head || document.documentElement).appendChild(script); 8 | -------------------------------------------------------------------------------- /public/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/content/utils/dom.ts: -------------------------------------------------------------------------------- 1 | export function getParentArticleNode(node: HTMLElement | null) { 2 | if (node === null) return null; 3 | if (node.tagName === 'ARTICLE') { 4 | return node; 5 | } 6 | return getParentArticleNode(node.parentElement); 7 | } 8 | 9 | export function getParentSectionNode(node: HTMLElement | null) { 10 | if (node === null) return null; 11 | if (node.tagName === 'SECTION') { 12 | return node; 13 | } 14 | return getParentSectionNode(node.parentElement); 15 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "module": "preserve", 5 | "noEmit": true, 6 | "target": "ESNext", 7 | "lib": ["es2022", "dom", "dom.iterable"], 8 | // "sourceMap": true, 9 | // "rootDir": "./src", 10 | "jsx": "react-jsx", 11 | "downlevelIteration": true, 12 | "noEmitOnError": true, 13 | "allowJs": true, 14 | "skipLibCheck": true, 15 | "allowSyntheticDefaultImports": true, 16 | "types": ["chrome-types", "firefox-webext-browser"] 17 | }, 18 | "exclude": ["node_modules", "public", "dist", "./src/*.js", "*.js"] 19 | } 20 | -------------------------------------------------------------------------------- /src/content/threads/button.ts: -------------------------------------------------------------------------------- 1 | import { handleThreadsPost } from './post'; 2 | 3 | function findContainerNode(target: HTMLAnchorElement) { 4 | let node = target.parentElement; 5 | while (node && node.getAttribute('data-pressable-container') !== 'true') { 6 | node = node.parentElement; 7 | } 8 | return node; 9 | } 10 | 11 | export function handleThreadsButton(target: HTMLAnchorElement) { 12 | const action = target.className.includes('download-btn') ? 'download' : 'open'; 13 | const container = findContainerNode(target); 14 | 15 | if (container instanceof HTMLDivElement) { 16 | handleThreadsPost(container, action); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const CONFIG_LIST = [ 2 | 'setting_show_open_in_new_tab_icon', 3 | 'setting_show_zip_download_icon', 4 | 'setting_enable_threads', 5 | 'setting_enable_video_controls', 6 | 'setting_format_replace_jpeg_with_jpg', 7 | 'setting_format_use_hash_id', 8 | 'setting_format_use_indexing', 9 | 'setting_enable_datetime_format', 10 | ]; 11 | 12 | export const DEFAULT_FILENAME_FORMAT = `{username}-{datetime}-{id}`; 13 | export const DEFAULT_DATETIME_FORMAT = 'YYYYMMDD_HHmmss'; 14 | 15 | export const EXTENSION_ID = 'oejjpeobjicdpgaijialfpfcbdnanajk'; 16 | 17 | export const CLASS_CUSTOM_BUTTON = 'custom-btn'; 18 | 19 | 20 | export const MESSAGE_OPEN_URL = "open_url" 21 | export const MESSAGE_ZIP_DOWNLOAD = "zip_download" -------------------------------------------------------------------------------- /src/popup/SettingItem.tsx: -------------------------------------------------------------------------------- 1 | const SettingItem: React.FC<{ 2 | value: boolean; 3 | setValue: React.Dispatch>; 4 | label: string; 5 | id: string; 6 | onChange?: () => void; 7 | }> = ({ label, value, setValue, id, onChange }) => { 8 | return ( 9 |
10 | { 15 | chrome.storage.sync.set({ [id]: !value }); 16 | setValue((p) => !p); 17 | if (onChange) onChange(); 18 | }} 19 | /> 20 | 21 |
22 | ); 23 | }; 24 | 25 | export default SettingItem; 26 | -------------------------------------------------------------------------------- /src/content/profile.ts: -------------------------------------------------------------------------------- 1 | import {downloadResource, openInNewTab} from './utils/fn'; 2 | 3 | export async function profileOnClicked(target: HTMLAnchorElement) { 4 | const {user_profile_pic_url} = await chrome.storage.local.get(['user_profile_pic_url']); 5 | const data = new Map(user_profile_pic_url); 6 | const arr = window.location.pathname.split('/').filter((e) => e); 7 | const username = arr.length === 1 ? arr[0] : document.querySelector('main header h2')?.textContent; 8 | const url = data.get(username) || document.querySelector('header img')?.getAttribute('src'); 9 | if (typeof url === 'string') { 10 | if (target.className.includes('download-btn')) { 11 | downloadResource({ 12 | url: url, 13 | fileId: username!, 14 | }); 15 | } else { 16 | openInNewTab(url); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 konka 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/options/index.ts: -------------------------------------------------------------------------------- 1 | import './index.scss'; 2 | 3 | const PERMISSIONS = { 4 | origins: ['https://www.instagram.com/*', 'https://www.threads.com/*'], 5 | }; 6 | const PERMS_DECLINED_MESSAGE = 'Permission request was declined.\nPlease try again.'; 7 | 8 | const permissionRequestButtons = document.getElementsByClassName('permissions-request'); 9 | 10 | for (const elem of permissionRequestButtons) { 11 | elem.addEventListener('click', permissionsRequest); 12 | } 13 | 14 | async function permissionsRequest(event: any) { 15 | event.stopPropagation(); 16 | const result = await browser.permissions.request(PERMISSIONS); 17 | 18 | if (result) { 19 | document.body.classList.add('permissions-granted'); 20 | } else { 21 | window.alert(PERMS_DECLINED_MESSAGE); 22 | } 23 | } 24 | 25 | async function setupSettingsUI() { 26 | const hasPermissions = await browser.permissions.contains(PERMISSIONS); 27 | 28 | if (hasPermissions) { 29 | document.body.classList.add('permissions-granted'); 30 | } else { 31 | document.body.classList.remove('permissions-granted'); 32 | } 33 | } 34 | 35 | setupSettingsUI(); 36 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import js from '@eslint/js'; 3 | import tseslint from 'typescript-eslint'; 4 | import globals from 'globals'; 5 | 6 | export default tseslint.config( 7 | { 8 | // config with just ignores is the replacement for `.eslintignore` 9 | ignores: ['dist/**'], 10 | }, 11 | { languageOptions: { globals: globals.browser } }, 12 | { 13 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 14 | rules: { 15 | '@typescript-eslint/no-explicit-any': 'off', 16 | '@typescript-eslint/no-unused-vars': 'warn', 17 | '@typescript-eslint/no-var-requires': 'off', 18 | '@typescript-eslint/ban-ts-comment': 'off', 19 | 'no-case-declarations': 'off', 20 | 'prefer-rest-params': 'warn', 21 | 'no-empty': 'off', 22 | 'no-restricted-syntax': [ 23 | 'error', 24 | { 25 | selector: 26 | 'CallExpression[callee.property.name=/querySelector|querySelectorAll/] Literal[value=/\'[^\']*\\baria-label\\b[^\']*\'|"[^"]*\\baria-label\\b[^"]*"/]', 27 | message: '禁止在 querySelector 中使用 aria-label 选择器,请改用 data-* 或 class。', 28 | }, 29 | ], 30 | }, 31 | } 32 | ); 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## instagram-download-browser-extension 2 | 3 | Install on [Chrome](https://chrome.google.com/webstore/detail/media-resources-enhancer/oejjpeobjicdpgaijialfpfcbdnanajk) 4 | or [Firefox](https://addons.mozilla.org/addon/ins-downloader/) 5 | 6 | ![image](https://github.com/TheKonka/instagram-download-browser-extension/assets/22173084/3ee34a30-5747-4a98-a129-bf030182f1d8) 7 | ![image](https://github.com/TheKonka/instagram-download-browser-extension/assets/22173084/f6988f38-46fc-4c9c-a37e-35a25e71dbe4) 8 | 9 | > You must open this setting manually on firefox. 10 | 11 | > If you feel slow after a while of installation, try to reinstall the extension. 12 | 13 | > If you encounter any issues, please try the latest version first to see if it resolves the problem. 14 | 15 | ### Screenshot 16 | 17 | ![283711](https://github.com/TheKonka/instagram-download-browser-extension/assets/22173084/98b823d7-c873-4290-a230-949e8d6f3b6f) 18 | ![283710](https://github.com/TheKonka/instagram-download-browser-extension/assets/22173084/ec1d017e-7a39-49fd-bda9-d988b1cd045b) 19 | 20 | ### Dependencies 21 | 22 | - [dayjs](https://github.com/iamkun/dayjs/) ([MIT License](https://github.com/iamkun/dayjs/blob/dev/LICENSE)) 23 | - [React](https://github.com/facebook/react) ([MIT License](https://github.com/facebook/react/blob/main/LICENSE)) 24 | 25 | ### Thanks 26 | 27 | [Instagram_Download_Button](https://github.com/y252328/Instagram_Download_Button) 28 | 29 | ### Licensing 30 | 31 | The source code is licensed under MIT. License is available [here](/LICENSE). 32 | 33 | ### Sponsor 34 | 35 | https://qaq.dad/sponsor -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import { argv } from 'node:process'; 2 | import { cp, readFile, writeFile, rm } from 'node:fs/promises'; 3 | 4 | import pkg from './package.json' with { type: 'json' }; 5 | import * as esbuild from 'esbuild'; 6 | import { sassPlugin } from 'esbuild-sass-plugin'; 7 | 8 | const platform = argv[2]; 9 | 10 | try { 11 | await rm(`dist/${platform}`, { recursive: true }); 12 | } catch { } 13 | 14 | const entryPoints = ['src/content/index.ts', 'src/popup/index.tsx', 'src/options/index.ts']; 15 | 16 | if (platform === 'chrome') { 17 | entryPoints.push('src/background/chrome.ts', 'src/xhr.ts', 'src/inject.ts'); 18 | } 19 | if (platform === 'firefox') { 20 | entryPoints.push('src/background/firefox.ts'); 21 | } 22 | 23 | const ctx = await esbuild.context({ 24 | entryPoints, 25 | outdir: `dist/${platform}`, 26 | bundle: true, 27 | plugins: [ 28 | sassPlugin({ 29 | embedded:true 30 | }), 31 | { 32 | name: 'copy-manifest', 33 | setup(build) { 34 | build.onEnd(async () => { 35 | await cp('public', `dist/${platform}`, { recursive: true }); 36 | const contents = await readFile(`./src/manifest.${platform}.json`, { encoding: 'utf8' }); 37 | const replacedContents = contents.replace(/__MSG_extVersion__/g, pkg.version); 38 | await writeFile(`dist/${platform}/manifest.json`, replacedContents, { encoding: 'utf8' }); 39 | console.log(`[${Date()}] manifest copied and replaced successfully`); 40 | }); 41 | }, 42 | }, 43 | ], 44 | }); 45 | 46 | ctx.watch(); 47 | -------------------------------------------------------------------------------- /src/manifest.firefox.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Instagram Downloader", 4 | "version": "__MSG_extVersion__", 5 | "description": "Help users download images and videos easily on Instagram", 6 | "homepage_url": "https://github.com/TheKonka/instagram-download-browser-extension", 7 | "icons": { 8 | "16": "icons/icon-16.png", 9 | "32": "icons/icon-32.png", 10 | "48": "icons/icon-48.png", 11 | "96": "icons/icon-96.png", 12 | "128": "icons/icon-128.png" 13 | }, 14 | "options_ui": { 15 | "open_in_tab": false, 16 | "page": "options/index.html" 17 | }, 18 | "action": { 19 | "default_icon": { 20 | "16": "icons/icon-16.png", 21 | "32": "icons/icon-32.png" 22 | }, 23 | "default_title": "Instagram Downloader", 24 | "default_popup": "popup/index.html" 25 | }, 26 | "host_permissions": [ 27 | "https://www.instagram.com/*", 28 | "https://www.threads.com/*" 29 | ], 30 | "permissions": [ 31 | "storage", 32 | "unlimitedStorage", 33 | "webRequest", 34 | "webRequestBlocking", 35 | "webRequestFilterResponse" 36 | ], 37 | "background": { 38 | "scripts": [ 39 | "background/firefox.js" 40 | ] 41 | }, 42 | "browser_specific_settings": { 43 | "gecko": { 44 | "id": "1094918@gmail.com" 45 | }, 46 | "gecko_android": { 47 | "strict_min_version": "120.0a1" 48 | } 49 | }, 50 | "content_scripts": [ 51 | { 52 | "js": [ 53 | "content/index.js" 54 | ], 55 | "matches": [ 56 | "https://www.instagram.com/*", 57 | "https://www.threads.com/*" 58 | ] 59 | } 60 | ] 61 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "instagram-download-browser-extension", 3 | "version": "2.3.9", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "build:chrome": "npx tsc --noEmit && node esbuild.config.mjs chrome", 9 | "build:ff": "npx tsc --noEmit && node esbuild.config.mjs firefox", 10 | "android": "cd dist/firefox && node ../../node_modules/web-ext/bin/web-ext.js run -t firefox-android --android-device=45lrfakru8a6kzyh --firefox-apk org.mozilla.fenix", 11 | "test": "echo \"Error: no test specified\" && exit 1", 12 | "lint": "npx eslint --fix" 13 | }, 14 | "keywords": [ 15 | "instagram", 16 | "download", 17 | "browser", 18 | "extension" 19 | ], 20 | "author": { 21 | "name": "Konka", 22 | "email": "1094918@gmail.com" 23 | }, 24 | "homepage": "https://github.com/TheKonka/instagram-download-browser-extension", 25 | "bugs": { 26 | "url": "https://github.com/TheKonka/instagram-download-browser-extension/issues", 27 | "email": "1094918@gmail.com" 28 | }, 29 | "license": "SEE LICENSE IN LICENSE", 30 | "dependencies": { 31 | "@zip.js/zip.js": "^2.8.11", 32 | "dayjs": "^1.11.19", 33 | "react": "^18.3.1", 34 | "react-dom": "^18.3.1" 35 | }, 36 | "devDependencies": { 37 | "@eslint/js": "^9.39.2", 38 | "@types/firefox-webext-browser": "^143.0.0", 39 | "@types/react": "^18.3.27", 40 | "@types/react-dom": "^18.3.7", 41 | "chrome-types": "^0.1.396", 42 | "esbuild": "^0.27.1", 43 | "esbuild-sass-plugin": "^3.3.1", 44 | "eslint": "^9.39.2", 45 | "globals": "^16.5.0", 46 | "sass": "^1.96.0", 47 | "sass-embedded": "^1.96.0", 48 | "typescript": "^5.9.3", 49 | "typescript-eslint": "^8.49.0", 50 | "web-ext": "^9.2.0" 51 | }, 52 | "engines": { 53 | "node": ">=18" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /public/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | settings 7 | 8 | 9 | 10 | 11 |

Thank you for using this extension!

12 | 13 |
14 |
15 |

Permissions are required to continue

16 |

This extension must be assigned the following manifest v3 permissions to work properly:

17 |
    18 |
  • Assign network request rules on www.instagram.com and www.threads.com
  • 19 |
20 |
21 | 24 |
25 |
26 |
27 | 28 |

For more options, please open the popup by clicking the extension's action icon.

29 | 30 |
31 |

About / License

32 | 33 |

34 | See 35 | Source code on Github 38 |

39 |

Please consider starring the project to show your ❤️ and support.

40 | 41 |

Copyright 2025

42 |
43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /src/manifest.chrome.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Media Resources Enhancer", 4 | "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55kP2SZWaP45Wo4wpK7nZxMu5GZmsmeevhxOck2h11z/8KoQVEElc1L+fytaL+M87GhB/SVmUvgWcS/WOgnIyACJyVplx4brtpW3+qLNqQGyb9n4FfBV9bzmL/qHDi0xpDboxQirOM1Hequk1/gS74dTlF6g0jo+23XX7qOmGJi/UGrI/59nwqbT5iNloPL/TdYuXLhoPK7W4/nMNRPPZWs9TkGnQDlkdSUTCvABQxrZrhK61AEoOVng3cEbxoShErUsxZTyUvdeg31m2fdwx2EVqdZ7K7IBDs3D7sHEhqmDigocffxRmHv9QfIENcV+fmzXDRyVU6T72yac4qIjhwIDAQAB", 5 | "version": "__MSG_extVersion__", 6 | "description": "Help you download images and videos easily on Instagram", 7 | "homepage_url": "https://github.com/TheKonka/instagram-download-browser-extension", 8 | "icons": { 9 | "16": "icons/icon-16.png", 10 | "32": "icons/icon-32.png", 11 | "48": "icons/icon-48.png", 12 | "96": "icons/icon-96.png", 13 | "128": "icons/icon-128.png" 14 | }, 15 | "action": { 16 | "default_icon": { 17 | "16": "icons/icon-16.png", 18 | "32": "icons/icon-32.png" 19 | }, 20 | "default_title": "Instagram Downloader", 21 | "default_popup": "popup/index.html" 22 | }, 23 | "permissions": [ 24 | "storage", 25 | "unlimitedStorage" 26 | ], 27 | "background": { 28 | "service_worker": "background/chrome.js" 29 | }, 30 | "content_scripts": [ 31 | { 32 | "js": [ 33 | "content/index.js" 34 | ], 35 | "matches": [ 36 | "https://www.instagram.com/*", 37 | "https://www.threads.com/*" 38 | ] 39 | }, 40 | { 41 | "matches": [ 42 | "https://www.instagram.com/*", 43 | "https://www.threads.com/*" 44 | ], 45 | "js": [ 46 | "inject.js" 47 | ], 48 | "run_at": "document_start" 49 | } 50 | ], 51 | "web_accessible_resources": [ 52 | { 53 | "resources": [ 54 | "xhr.js" 55 | ], 56 | "matches": [ 57 | "https://www.instagram.com/*", 58 | "https://www.threads.com/*" 59 | ] 60 | } 61 | ], 62 | "externally_connectable": { 63 | "matches": [ 64 | "https://www.instagram.com/*", 65 | "https://www.threads.com/*" 66 | ] 67 | } 68 | } -------------------------------------------------------------------------------- /src/types/profileReel.d.ts: -------------------------------------------------------------------------------- 1 | export namespace ProfileReel { 2 | export interface Root { 3 | data: Data; 4 | extensions: Extensions; 5 | status: string; 6 | } 7 | 8 | export interface Data { 9 | xdt_api__v1__clips__user__connection_v2: XdtApiV1ClipsUserConnectionV2; 10 | xdt_viewer: XdtViewer; 11 | } 12 | 13 | export interface XdtApiV1ClipsUserConnectionV2 { 14 | edges: Edge[]; 15 | page_info: PageInfo; 16 | } 17 | 18 | export interface Edge { 19 | node: Node; 20 | cursor: string; 21 | } 22 | 23 | export interface Node { 24 | media: Media; 25 | __typename: string; 26 | } 27 | 28 | export interface Media { 29 | pk: string; 30 | id: string; 31 | code: string; 32 | media_overlay_info: any; 33 | media_type: number; 34 | user: User; 35 | video_versions?: VideoVersion[]; 36 | carousel_media: any; 37 | image_versions2: ImageVersions2; 38 | preview: any; 39 | product_type: string; 40 | play_count: any; 41 | view_count?: number; 42 | like_and_view_counts_disabled: boolean; 43 | comment_count: number; 44 | like_count: number; 45 | audience: any; 46 | clips_tab_pinned_user_ids: any[]; 47 | original_height: number; 48 | original_width: number; 49 | } 50 | 51 | export interface User { 52 | pk: string; 53 | id: string; 54 | } 55 | 56 | export interface VideoVersion { 57 | url: string; 58 | type: number; 59 | } 60 | 61 | export interface ImageVersions2 { 62 | candidates: Candidate[]; 63 | } 64 | 65 | export interface Candidate { 66 | height: number; 67 | url: string; 68 | width: number; 69 | } 70 | 71 | export interface PageInfo { 72 | end_cursor: string; 73 | has_next_page: boolean; 74 | has_previous_page: boolean; 75 | start_cursor: any; 76 | } 77 | 78 | export interface XdtViewer { 79 | user: User2; 80 | } 81 | 82 | export interface User2 { 83 | id: string; 84 | } 85 | 86 | export interface Extensions { 87 | is_final: boolean; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/background/fn.ts: -------------------------------------------------------------------------------- 1 | import type { Stories } from '../types/stories'; 2 | import type { Highlight } from '../types/highlights'; 3 | import type { Reels } from '../types/reels'; 4 | import type { ProfileReel } from '../types/profileReel'; 5 | 6 | // save highlights data from json 7 | export async function saveHighlights(jsonData: Record) { 8 | if (Array.isArray(jsonData.data?.xdt_api__v1__feed__reels_media__connection?.edges)) { 9 | const data = (jsonData as Highlight.Root).data.xdt_api__v1__feed__reels_media__connection.edges.map((i) => i.node); 10 | const { highlights_data } = await chrome.storage.local.get(['highlights_data']); 11 | const newMap = new Map(highlights_data); 12 | data.forEach((i) => newMap.set(i.id, i)); 13 | await chrome.storage.local.set({ highlights_data: [...newMap] }); 14 | 15 | //? The presentation stories in home page top url is /stories/{username} now 16 | //? before was /stories/highlights/{pk} 17 | //? so we need to save the data to stories_reels_media 18 | saveStoriesToLocal(data); 19 | } 20 | } 21 | 22 | // save reels data from json 23 | export async function saveReels(jsonData: Record) { 24 | if (Array.isArray(jsonData.data?.xdt_api__v1__clips__home__connection_v2?.edges)) { 25 | const data = (jsonData as Reels.Root).data.xdt_api__v1__clips__home__connection_v2.edges.map((i) => i.node.media); 26 | const { reels_edges_data } = await chrome.storage.local.get(['reels_edges_data']); 27 | const newMap = new Map(reels_edges_data); 28 | data.forEach((i) => newMap.set(i.code, i)); 29 | await chrome.storage.local.set({ reels_edges_data: [...newMap] }); 30 | } 31 | } 32 | 33 | export async function saveProfileReel(jsonData: Record) { 34 | if (Array.isArray(jsonData.data?.xdt_api__v1__clips__user__connection_v2?.edges)) { 35 | const data = (jsonData as ProfileReel.Root).data.xdt_api__v1__clips__user__connection_v2.edges.map((i) => i.node.media); 36 | const { profile_reels_edges_data } = await chrome.storage.local.get(['profile_reels_edges_data']); 37 | const newMap = new Map(profile_reels_edges_data); 38 | data.forEach((i) => newMap.set(i.code, i)); 39 | await chrome.storage.local.set({ profile_reels_edges_data: [...newMap] }); 40 | } 41 | } 42 | 43 | // save stories data from json 44 | export async function saveStories(jsonData: Record) { 45 | if (Array.isArray(jsonData.data?.xdt_api__v1__feed__reels_media?.reels_media)) { 46 | const data = (jsonData as Stories.Root).data.xdt_api__v1__feed__reels_media.reels_media; 47 | saveStoriesToLocal(data); 48 | } 49 | } 50 | 51 | export async function saveStoriesToLocal(data: Stories.ReelsMedum[]) { 52 | const { stories_reels_media } = await chrome.storage.local.get(['stories_reels_media']); 53 | const newMap = new Map(stories_reels_media); 54 | data.forEach((i) => newMap.set(i.id, i)); 55 | await chrome.storage.local.set({ stories_reels_media: [...newMap] }); 56 | } 57 | -------------------------------------------------------------------------------- /src/content/reels.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import {DownloadParams, downloadResource, fetchHtml, getMediaName, getUrlFromInfoApi, openInNewTab} from './utils/fn'; 3 | import type {Reels} from '../types/reels'; 4 | 5 | function findReels(obj: Record): Reels.XdtApiV1ClipsHomeConnectionV2 | undefined { 6 | for (const key in obj) { 7 | if (key === 'xdt_api__v1__clips__home__connection_v2') { 8 | return obj[key]; 9 | } else if (typeof obj[key] === 'object' && obj[key] !== null) { 10 | const result = findReels(obj[key]); 11 | if (result) { 12 | return result; 13 | } 14 | } 15 | } 16 | } 17 | 18 | export async function reelsOnClicked(target: HTMLAnchorElement) { 19 | const final = (obj: DownloadParams) => { 20 | if (target.className.includes('download-btn')) { 21 | downloadResource(obj); 22 | } else { 23 | openInNewTab(obj.url); 24 | } 25 | }; 26 | 27 | const handleMedia = (media: Reels.Media) => { 28 | const url = media.video_versions?.[0].url || media.image_versions2.candidates[0].url; 29 | final({ 30 | url: url, 31 | username: media.user.username, 32 | datetime: dayjs.unix(media.taken_at), 33 | fileId: getMediaName(url), 34 | }); 35 | }; 36 | 37 | const {reels_edges_data} = await chrome.storage.local.get(['reels_edges_data']); 38 | const code = window.location.pathname.split('/').at(-2); 39 | const media = new Map(reels_edges_data).get(code) as Reels.Media | undefined; 40 | if (media) { 41 | handleMedia(media); 42 | return; 43 | } 44 | 45 | const scripts = await fetchHtml(); 46 | for (const script of [...window.document.scripts, ...scripts]) { 47 | try { 48 | const innerHTML = script.innerHTML; 49 | const data = JSON.parse(innerHTML); 50 | if (innerHTML.includes('xdt_api__v1__clips__home__connection_v2')) { 51 | const res = findReels(data); 52 | if (res) { 53 | for (const item of res.edges) { 54 | if (item.node.media.code === code) { 55 | handleMedia(item.node.media); 56 | return; 57 | } 58 | } 59 | } 60 | } 61 | } catch { 62 | } 63 | } 64 | 65 | const wrapperNode = target.parentNode!.parentNode as HTMLDivElement; 66 | try { 67 | const res = await getUrlFromInfoApi(wrapperNode); 68 | if (!res) return; 69 | console.log('url', res.url); 70 | final({ 71 | url: res.url, 72 | username: res.owner, 73 | datetime: dayjs.unix(res.taken_at), 74 | fileId: getMediaName(res.url), 75 | }); 76 | } catch (e: any) { 77 | alert('Reels Download Failed!'); 78 | console.log(`Uncaught in postDetailOnClicked(): ${e}\n${e.stack}`); 79 | return; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/content/threads/index.ts: -------------------------------------------------------------------------------- 1 | import { CLASS_CUSTOM_BUTTON } from '../../constants'; 2 | import { addCustomBtn } from '../button'; 3 | 4 | function handleList(list: Element[]) { 5 | const iconColor = window.getComputedStyle(document.body).backgroundColor === 'rgb(0, 0, 0)' ? 'white' : 'black'; 6 | list.forEach((n) => { 7 | const node = n.firstElementChild?.firstElementChild; 8 | if (!node) return; 9 | // text post doesn't need to add button 10 | if (node.querySelector('picture') || node.querySelector('video')) { 11 | node 12 | .querySelectorAll( 13 | 'path[d="M1.34375 7.53125L1.34375 7.54043C1.34374 8.04211 1.34372 8.76295 1.6611 9.65585C1.9795 10.5516 2.60026 11.5779 3.77681 12.7544C5.59273 14.5704 7.58105 16.0215 8.33387 16.5497C8.73525 16.8313 9.26573 16.8313 9.66705 16.5496C10.4197 16.0213 12.4074 14.5703 14.2232 12.7544C15.3997 11.5779 16.0205 10.5516 16.3389 9.65585C16.6563 8.76296 16.6563 8.04211 16.6562 7.54043V7.53125C16.6562 5.23466 15.0849 3.25 12.6562 3.25C11.5214 3.25 10.6433 3.78244 9.99228 4.45476C9.59009 4.87012 9.26356 5.3491 9 5.81533C8.73645 5.3491 8.40991 4.87012 8.00772 4.45476C7.35672 3.78244 6.47861 3.25 5.34375 3.25C2.9151 3.25 1.34375 5.23466 1.34375 7.53125Z"]' 14 | ) 15 | .forEach((likeBtn) => { 16 | const btnContainer = likeBtn.parentElement?.parentElement?.parentElement?.parentElement?.parentElement; 17 | if (btnContainer && btnContainer.getElementsByClassName(CLASS_CUSTOM_BUTTON).length === 0) { 18 | addCustomBtn(btnContainer, iconColor); 19 | } 20 | }); 21 | } 22 | }); 23 | } 24 | 25 | export function handleThreads() { 26 | const pathname = window.location.pathname; 27 | const pathnameList = pathname.split('/').filter((e) => e); 28 | 29 | const isPostDetailPage = pathnameList.length === 3 && pathnameList[1] === 'post'; 30 | 31 | if (pathname === '/') { 32 | const notLoginNode = document.querySelector('div[data-nosnippet="true"]>div>div>div'); 33 | if (notLoginNode) { 34 | handleList(Array.from(notLoginNode.children)); 35 | } else { 36 | const columnHeaders = document.querySelectorAll('div[id="barcelona-header"]'); 37 | for (const header of columnHeaders) { 38 | const wrapper = header.parentElement?.parentElement?.parentElement?.querySelector( 39 | 'div[data-visualcompletion="ignore"][data-thumb="1"]' 40 | )?.parentElement?.firstElementChild; 41 | if (wrapper) { 42 | handleList(Array.from(wrapper.children)); 43 | } 44 | } 45 | } 46 | } else if (pathname === '/search') { 47 | const wrapperNode = document.querySelector('div[data-thumb="1"][data-visualcompletion="ignore"]')?.parentElement?.firstElementChild; 48 | if (wrapperNode) { 49 | handleList(Array.from(wrapperNode.children)); 50 | } 51 | } else if (isPostDetailPage) { 52 | const layout = document.querySelectorAll('#barcelona-page-layout'); 53 | let wrapper; 54 | for (const item of layout) { 55 | if (item.parentElement?.hidden) { 56 | continue; 57 | } else { 58 | wrapper = item; 59 | break; 60 | } 61 | } 62 | const list = wrapper?.querySelector('div[role=region]>div:nth-child(1)>div:nth-child(1)>div:nth-child(1)>div:nth-child(1)')?.children; 63 | if (list) { 64 | handleList(Array.from(list)); 65 | } 66 | } else if (pathname.startsWith('/@')) { 67 | const layout = document.querySelectorAll('#barcelona-page-layout'); 68 | let wrapper; 69 | for (const item of layout) { 70 | if (item.parentElement?.hidden) { 71 | continue; 72 | } else { 73 | wrapper = item; 74 | break; 75 | } 76 | } 77 | let list; 78 | if (wrapper) { 79 | list = wrapper.querySelector('div[role=region]>div>div:nth-child(4)>div:nth-child(1)>div:nth-child(1)')?.children; 80 | } else { 81 | list = document.querySelector('header')?.nextElementSibling?.querySelector('#barcelona-page-layout>div:nth-child(3)')?.children; 82 | } 83 | if (list) { 84 | handleList(Array.from(list)); 85 | } 86 | } else { 87 | const progressbar = document.querySelector('div[role=progressbar]'); 88 | const list = progressbar?.parentElement?.parentElement?.parentElement?.querySelectorAll( 89 | ':scope>div>div>div>div>div:nth-child(2)' 90 | ); 91 | if (list) { 92 | handleList(Array.from(list)); 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/types/stories.d.ts: -------------------------------------------------------------------------------- 1 | export namespace Stories { 2 | export interface Root { 3 | data: Data; 4 | extensions: Extensions; 5 | } 6 | 7 | export interface Data { 8 | xdt_viewer: XdtViewer; 9 | xdt_api__v1__feed__reels_media: XdtApiV1FeedReelsMedia; 10 | } 11 | 12 | export interface XdtViewer { 13 | user: User; 14 | } 15 | 16 | export interface User { 17 | pk: string; 18 | id: string; 19 | can_see_organic_insights: boolean; 20 | } 21 | 22 | export interface XdtApiV1FeedReelsMedia { 23 | reels_media: ReelsMedum[]; 24 | } 25 | 26 | export interface ReelsMedum { 27 | id: string; 28 | items: Item[]; 29 | user: User3; 30 | seen: number; 31 | cover_media: any; 32 | title: any; 33 | reel_type: string; 34 | } 35 | 36 | export interface Item { 37 | pk: string; 38 | id: string; 39 | viewer_count: any; 40 | video_duration?: number; 41 | media_type: number; 42 | taken_at: number; 43 | story_cta: any; 44 | user: User2; 45 | has_liked: boolean; 46 | sharing_friction_info: SharingFrictionInfo; 47 | media_overlay_info: any; 48 | image_versions2: ImageVersions2; 49 | accessibility_caption?: string; 50 | organic_tracking_token: string; 51 | is_dash_eligible?: number; 52 | number_of_qualities?: number; 53 | video_dash_manifest?: string; 54 | video_versions?: VideoVersion[]; 55 | boosted_status: any; 56 | original_width: number; 57 | original_height: number; 58 | story_countdowns: any; 59 | story_questions: any; 60 | story_sliders: any; 61 | preview: any; 62 | boost_unavailable_identifier: any; 63 | boost_unavailable_reason: any; 64 | product_type: string; 65 | audience: any; 66 | can_viewer_reshare: any; 67 | expiring_at: number; 68 | ig_media_sharing_disabled: boolean; 69 | story_music_stickers: any; 70 | carousel_media_count: any; 71 | carousel_media: any; 72 | visual_comment_reply_sticker_info: any; 73 | // story_bloks_stickers: any; 74 | // story_link_stickers: any; 75 | // story_hashtags: any; 76 | // story_locations?: StoryLocation[]; 77 | // story_feed_media?: StoryFeedMedum[]; 78 | // text_post_share_to_ig_story_stickers: any; 79 | // is_paid_partnership: boolean; 80 | // sponsor_tags: any; 81 | // reshared_story_media_author: any; 82 | // story_app_attribution?: StoryAppAttribution; 83 | // has_translation: boolean; 84 | // can_see_insights_as_brand: boolean; 85 | // viewers: any; 86 | // can_reply: boolean; 87 | // can_reshare: boolean; 88 | // has_audio?: boolean; 89 | // inventory_source: any; 90 | // __typename: string; 91 | } 92 | 93 | export interface User2 { 94 | pk: string; 95 | id: any; 96 | interop_messaging_user_fbid: any; 97 | is_private: boolean; 98 | profile_pic_url: any; 99 | username: any; 100 | } 101 | 102 | export interface SharingFrictionInfo { 103 | should_have_sharing_friction: boolean; 104 | bloks_app_url: any; 105 | } 106 | 107 | export interface ImageVersions2 { 108 | candidates: Candidate[]; 109 | } 110 | 111 | export interface Candidate { 112 | height: number; 113 | url: string; 114 | width: number; 115 | } 116 | 117 | export interface VideoVersion { 118 | type: number; 119 | url: string; 120 | } 121 | 122 | export interface StoryLocation { 123 | x: number; 124 | y: number; 125 | width: number; 126 | height: number; 127 | rotation: number; 128 | location: Location; 129 | id: any; 130 | } 131 | 132 | export interface Location { 133 | pk: number; 134 | } 135 | 136 | export interface StoryFeedMedum { 137 | x: number; 138 | y: number; 139 | width: number; 140 | height: number; 141 | rotation: number; 142 | media_code: string; 143 | id: any; 144 | product_type: string; 145 | } 146 | 147 | export interface StoryAppAttribution { 148 | app_action_text: string; 149 | app_icon_url: string; 150 | name: string; 151 | content_url: string; 152 | link: any; 153 | id: string; 154 | } 155 | 156 | export interface User3 { 157 | username: string; 158 | id: any; 159 | pk: string; 160 | profile_pic_url: string; 161 | interop_messaging_user_fbid: string; 162 | is_private: boolean; 163 | is_verified: boolean; 164 | friendship_status: FriendshipStatus; 165 | } 166 | 167 | export interface FriendshipStatus { 168 | following: boolean; 169 | } 170 | 171 | export interface Extensions { 172 | is_final: boolean; 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/types/reels.d.ts: -------------------------------------------------------------------------------- 1 | export namespace Reels { 2 | export interface Root { 3 | data: Data; 4 | extensions: Extensions; 5 | status: string; 6 | } 7 | 8 | export interface Data { 9 | xdt_api__v1__clips__home__connection_v2: XdtApiV1ClipsHomeConnectionV2; 10 | } 11 | 12 | export interface XdtApiV1ClipsHomeConnectionV2 { 13 | edges: Edge[]; 14 | page_info: PageInfo; 15 | } 16 | 17 | export interface Edge { 18 | node: Node; 19 | cursor: string; 20 | } 21 | 22 | export interface Node { 23 | media: Media; 24 | __typename: string; 25 | } 26 | 27 | export interface Media { 28 | code: string; 29 | pk: string; 30 | actor_fbid: any; 31 | has_liked: boolean; 32 | comments_disabled: any; 33 | like_count: number; 34 | user: User; 35 | product_type: string; 36 | view_count: any; 37 | like_and_view_counts_disabled: boolean; 38 | owner: Owner; 39 | id: string; 40 | organic_tracking_token: string; 41 | clips_metadata: ClipsMetadata; 42 | comment_count: number; 43 | taken_at: number; 44 | caption: Caption; 45 | media_type: number; 46 | commenting_disabled_for_viewer: any; 47 | can_reshare: any; 48 | can_viewer_reshare: boolean; 49 | audience: any; 50 | ig_media_sharing_disabled: boolean; 51 | inventory_source: string; 52 | logging_info_token: string; 53 | carousel_media: any; 54 | image_versions2: ImageVersions2; 55 | media_overlay_info: any; 56 | share_urls: any; 57 | saved_collection_ids: any; 58 | has_viewer_saved: any; 59 | original_height: number; 60 | original_width: number; 61 | is_dash_eligible: number; 62 | number_of_qualities: number; 63 | video_dash_manifest: string; 64 | video_versions: VideoVersion[]; 65 | has_audio: boolean; 66 | creative_config?: CreativeConfig; 67 | usertags?: Usertags; 68 | location?: Location; 69 | clips_attribution_info: any; 70 | invited_coauthor_producers: any[]; 71 | carousel_media_count: any; 72 | preview: any; 73 | } 74 | 75 | export interface User { 76 | pk: string; 77 | username: string; 78 | profile_pic_url: string; 79 | id: string; 80 | is_verified: boolean; 81 | is_unpublished: boolean; 82 | is_private: boolean; 83 | friendship_status: FriendshipStatus; 84 | } 85 | 86 | export interface FriendshipStatus { 87 | following: boolean; 88 | } 89 | 90 | export interface Owner { 91 | pk: string; 92 | username: string; 93 | id: string; 94 | is_unpublished: boolean; 95 | } 96 | 97 | export interface ClipsMetadata { 98 | music_info?: MusicInfo; 99 | original_sound_info?: OriginalSoundInfo; 100 | } 101 | 102 | export interface MusicInfo { 103 | music_asset_info: MusicAssetInfo; 104 | music_consumption_info: MusicConsumptionInfo; 105 | } 106 | 107 | export interface MusicAssetInfo { 108 | audio_cluster_id: string; 109 | cover_artwork_thumbnail_uri: string; 110 | title: string; 111 | display_artist: string; 112 | is_explicit: boolean; 113 | } 114 | 115 | export interface MusicConsumptionInfo { 116 | should_mute_audio: boolean; 117 | is_trending_in_clips: boolean; 118 | } 119 | 120 | export interface OriginalSoundInfo { 121 | audio_asset_id: string; 122 | ig_artist: IgArtist; 123 | consumption_info: ConsumptionInfo; 124 | original_audio_title: string; 125 | is_explicit: boolean; 126 | } 127 | 128 | export interface IgArtist { 129 | profile_pic_url: string; 130 | id: string; 131 | username: string; 132 | } 133 | 134 | export interface ConsumptionInfo { 135 | should_mute_audio_reason_type: any; 136 | is_trending_in_clips: boolean; 137 | } 138 | 139 | export interface Caption { 140 | text: string; 141 | pk: string; 142 | has_translation?: boolean; 143 | } 144 | 145 | export interface ImageVersions2 { 146 | candidates: Candidate[]; 147 | } 148 | 149 | export interface Candidate { 150 | height: number; 151 | url: string; 152 | width: number; 153 | } 154 | 155 | export interface VideoVersion { 156 | type: number; 157 | url: string; 158 | } 159 | 160 | export interface CreativeConfig { 161 | effect_configs: any; 162 | } 163 | 164 | export interface Usertags { 165 | in: In[]; 166 | } 167 | 168 | export interface In { 169 | user: User2; 170 | } 171 | 172 | export interface User2 { 173 | username: string; 174 | id: string; 175 | } 176 | 177 | export interface Location { 178 | pk: string; 179 | name: string; 180 | } 181 | 182 | export interface PageInfo { 183 | end_cursor: string; 184 | has_next_page: boolean; 185 | has_previous_page: boolean; 186 | start_cursor: any; 187 | } 188 | 189 | export interface Extensions { 190 | is_final: boolean; 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/background/chrome.ts: -------------------------------------------------------------------------------- 1 | import type {ReelsMedia} from '../types/global'; 2 | import {saveHighlights, saveProfileReel, saveReels, saveStories} from './fn'; 3 | import {CONFIG_LIST, MESSAGE_OPEN_URL} from '../constants'; 4 | 5 | chrome.runtime.onInstalled.addListener(async () => { 6 | const result = await chrome.storage.sync.get(CONFIG_LIST); 7 | CONFIG_LIST.forEach((i) => { 8 | if (result[i] === undefined) { 9 | chrome.storage.sync.set({ 10 | [i]: true, 11 | }); 12 | } 13 | }); 14 | }); 15 | 16 | chrome.runtime.onStartup.addListener(() => { 17 | chrome.storage.local.set({stories_user_ids: [], id_to_username_map: []}); 18 | }); 19 | 20 | chrome.runtime.onMessage.addListener((message, sender) => { 21 | console.log(message, sender); 22 | const {type, data} = message; 23 | if (type === MESSAGE_OPEN_URL) { 24 | chrome.tabs.create({url: data, index: sender.tab!.index + 1}); 25 | } 26 | return false; 27 | }); 28 | 29 | async function addThreads(data: any[]) { 30 | const {threads} = await chrome.storage.local.get(['threads']); 31 | const newMap = new Map(threads); 32 | for (const item of data) { 33 | const code = item?.post?.code; 34 | if (code) { 35 | newMap.set(code, item); 36 | } 37 | } 38 | await chrome.storage.local.set({threads: Array.from(newMap)}); 39 | } 40 | 41 | function findValueByKey(obj: Record, key: string): any { 42 | for (const property in obj) { 43 | if (Object.prototype.hasOwnProperty.call(obj, property)) { 44 | if (property === key) { 45 | return obj[property]; 46 | } else if (typeof obj[property] === 'object') { 47 | const result = findValueByKey(obj[property], key); 48 | if (result !== undefined) { 49 | return result; 50 | } 51 | } 52 | } 53 | } 54 | } 55 | 56 | chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => { 57 | // console.log(message, sender); 58 | const {type, data, api} = message; 59 | 60 | if (sender.origin === 'https://www.threads.com') { 61 | if (type === 'threads_searchResults') { 62 | data 63 | .split(/\s*for\s+\(;;\);\s*/) 64 | .filter((_: any) => _) 65 | .map(async (i: any) => { 66 | try { 67 | const result = findValueByKey(JSON.parse(i), 'searchResults'); 68 | if (result && Array.isArray(result.edges)) { 69 | await addThreads(result.edges.map((i: any) => i.node.thread.thread_items).flat()); 70 | } 71 | } catch { 72 | } 73 | }); 74 | } else { 75 | addThreads(data); 76 | } 77 | return false; 78 | } 79 | 80 | (async () => { 81 | if (type === 'stories') { 82 | const { 83 | stories_user_ids, 84 | id_to_username_map 85 | } = await chrome.storage.local.get(['stories_user_ids', 'id_to_username_map']); 86 | const nameToId = new Map(stories_user_ids); 87 | const idToName = new Map(id_to_username_map); 88 | nameToId.set(data.username, data.user_id); 89 | idToName.set(data.user_id, data.username); 90 | await chrome.storage.local.set({ 91 | stories_user_ids: Array.from(nameToId), 92 | id_to_username_map: Array.from(idToName) 93 | }); 94 | } else { 95 | try { 96 | const jsonData = JSON.parse(data); 97 | 98 | switch (api) { 99 | case 'https://www.instagram.com/api/graphql': 100 | saveStories(jsonData); 101 | break; 102 | case 'https://www.instagram.com/graphql/query': 103 | saveHighlights(jsonData); 104 | saveReels(jsonData); 105 | saveStories(jsonData); 106 | saveProfileReel(jsonData); 107 | break; 108 | // presentation stories in home page top 109 | case '/api/v1/feed/reels_media/?reel_ids=': 110 | const {reels, reels_media} = await chrome.storage.local.get(['reels', 'reels_media']); 111 | const newArr = (reels_media || []).filter( 112 | (i: ReelsMedia.ReelsMedum) => !(jsonData as ReelsMedia.Root).reels_media.find((j) => j.id === i.id) 113 | ); 114 | chrome.storage.local.set({ 115 | reels: Object.assign({}, reels, data.reels), 116 | reels_media: [...newArr, ...jsonData.reels_media], 117 | }); 118 | break; 119 | } 120 | } catch { 121 | } 122 | } 123 | sendResponse(); 124 | })(); 125 | 126 | return true; 127 | }); 128 | -------------------------------------------------------------------------------- /src/types/highlights.d.ts: -------------------------------------------------------------------------------- 1 | export namespace Highlight { 2 | export interface Root { 3 | data: Data; 4 | extensions: Extensions; 5 | status: string; 6 | } 7 | 8 | export interface Data { 9 | xdt_api__v1__feed__reels_media__connection: XdtApiV1FeedReelsMediaConnection; 10 | } 11 | 12 | export interface XdtApiV1FeedReelsMediaConnection { 13 | edges: Edge[]; 14 | page_info: PageInfo; 15 | } 16 | 17 | export interface Edge { 18 | node: Node; 19 | cursor: string; 20 | } 21 | 22 | export interface Node { 23 | id: string; 24 | items: Item[]; 25 | user: User2; 26 | reel_type: string; 27 | cover_media: CoverMedia; 28 | title: string; 29 | seen: any; 30 | __typename: string; 31 | } 32 | 33 | export interface Item { 34 | pk: string; 35 | id: string; 36 | viewer_count: any; 37 | video_duration?: number; 38 | media_type: number; 39 | audience: any; 40 | taken_at: number; 41 | story_cta: any; 42 | user: User; 43 | has_liked: boolean; 44 | viewers: any; 45 | sharing_friction_info: SharingFrictionInfo; 46 | can_viewer_reshare: any; 47 | expiring_at: any; 48 | ig_media_sharing_disabled: boolean; 49 | product_type: string; 50 | media_overlay_info: any; 51 | image_versions2: ImageVersions2; 52 | can_reply: boolean; 53 | can_reshare: boolean; 54 | has_audio?: boolean; 55 | story_music_stickers: any; 56 | carousel_media_count: any; 57 | carousel_media: any; 58 | accessibility_caption?: string; 59 | original_width: number; 60 | original_height: number; 61 | organic_tracking_token: string; 62 | is_dash_eligible?: number; 63 | number_of_qualities?: number; 64 | video_dash_manifest?: string; 65 | video_versions?: VideoVersion[]; 66 | visual_comment_reply_sticker_info: any; 67 | story_bloks_stickers?: StoryBloksSticker[]; 68 | story_link_stickers: any; 69 | story_hashtags?: StoryHashtag[]; 70 | story_locations: any; 71 | story_feed_media: any; 72 | text_post_share_to_ig_story_stickers: any; 73 | story_countdowns: any; 74 | story_questions: any; 75 | story_sliders: any; 76 | preview: any; 77 | is_paid_partnership: boolean; 78 | sponsor_tags: any; 79 | reshared_story_media_author: any; 80 | story_app_attribution: any; 81 | has_translation: boolean; 82 | boosted_status: any; 83 | can_see_insights_as_brand: boolean; 84 | boost_unavailable_identifier: any; 85 | boost_unavailable_reason: any; 86 | inventory_source: any; 87 | __typename: string; 88 | } 89 | 90 | export interface User { 91 | pk: string; 92 | id: any; 93 | is_private: boolean; 94 | profile_pic_url: any; 95 | username: any; 96 | interop_messaging_user_fbid: any; 97 | } 98 | 99 | export interface SharingFrictionInfo { 100 | should_have_sharing_friction: boolean; 101 | bloks_app_url: any; 102 | } 103 | 104 | export interface ImageVersions2 { 105 | candidates: Candidate[]; 106 | } 107 | 108 | export interface Candidate { 109 | height: number; 110 | url: string; 111 | width: number; 112 | } 113 | 114 | export interface VideoVersion { 115 | type: number; 116 | url: string; 117 | } 118 | 119 | export interface StoryBloksSticker { 120 | x: number; 121 | y: number; 122 | width: number; 123 | height: number; 124 | rotation: number; 125 | bloks_sticker: BloksSticker; 126 | id: any; 127 | } 128 | 129 | export interface BloksSticker { 130 | sticker_data: StickerData; 131 | id: string; 132 | } 133 | 134 | export interface StickerData { 135 | ig_mention: IgMention; 136 | } 137 | 138 | export interface IgMention { 139 | full_name: string; 140 | username: string; 141 | } 142 | 143 | export interface StoryHashtag { 144 | x: number; 145 | y: number; 146 | width: number; 147 | height: number; 148 | rotation: number; 149 | hashtag: Hashtag; 150 | id: any; 151 | } 152 | 153 | export interface Hashtag { 154 | name: string; 155 | id: string; 156 | } 157 | 158 | export interface User2 { 159 | interop_messaging_user_fbid: string; 160 | id: any; 161 | pk: string; 162 | friendship_status: any; 163 | username: string; 164 | is_private: boolean; 165 | profile_pic_url: string; 166 | is_verified: boolean; 167 | } 168 | 169 | export interface CoverMedia { 170 | cropped_image_version: CroppedImageVersion; 171 | full_image_version: any; 172 | } 173 | 174 | export interface CroppedImageVersion { 175 | url: string; 176 | } 177 | 178 | export interface PageInfo { 179 | end_cursor: string; 180 | has_next_page: boolean; 181 | has_previous_page: boolean; 182 | start_cursor: string; 183 | } 184 | 185 | export interface Extensions { 186 | is_final: boolean; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/options/index.scss: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | } 4 | *, 5 | *:before, 6 | *:after { 7 | box-sizing: inherit; 8 | } 9 | body { 10 | font-family: 'Noto Sans', 'SF Pro Text', 'Segoe UI', 'Calibri', sans-serif; 11 | max-width: 1162px; 12 | margin-left: auto; 13 | margin-right: auto; 14 | font-size: 15px; 15 | font-weight: 400; 16 | padding: 1rem; 17 | } 18 | strong { 19 | font-weight: 600; 20 | } 21 | .container { 22 | margin: 0 20px; 23 | } 24 | img { 25 | max-width: 100%; 26 | height: auto; 27 | } 28 | button, 29 | .button { 30 | min-width: 6.3em; 31 | font-weight: 600em; 32 | background-color: #eeeeee; 33 | border: none; 34 | border-radius: 4px; 35 | font-size: 1em; 36 | min-height: 32px; 37 | margin: 0 4px; 38 | padding: 4px; 39 | text-decoration: none; 40 | } 41 | button.primary, 42 | .button.primary { 43 | background-color: #0061e0; 44 | color: #fbfbfe; 45 | } 46 | button:focus, 47 | .button:focus { 48 | outline: 2px solid #0060df; 49 | outline-offset: 2px; 50 | } 51 | button:active, 52 | .button:active, 53 | button:hover, 54 | .button:hover, 55 | .uv .close:active, 56 | .uv .close:hover { 57 | background-color: #dededf; 58 | } 59 | button.primary:active, 60 | .button.primary:active, 61 | button.primary:hover, 62 | .button.primary:hover { 63 | background-color: #0250bb; 64 | } 65 | 66 | h1 { 67 | font-weight: 500; 68 | font-size: 1.5em; 69 | padding-top: 16px; 70 | margin-top: 16px; 71 | } 72 | h2 { 73 | font-weight: 400; 74 | font-size: 1.4em; 75 | border-top: 1px solid #d7d7db; 76 | padding-top: 16px; 77 | margin-top: 16px; 78 | } 79 | h3 { 80 | font-weight: 400; 81 | font-size: 1.3em; 82 | } 83 | .option { 84 | margin: 15px 0; 85 | } 86 | 87 | .section { 88 | background-color: #f8f8fa; 89 | padding: 16px; 90 | border-radius: 13px; 91 | margin: 18px 0; 92 | } 93 | .warning { 94 | background-color: #ccdff9; 95 | border: 1px solid #0061e0; 96 | } 97 | .section h3 { 98 | font-weight: 600; 99 | } 100 | ul { 101 | list-style-type: square; 102 | } 103 | p { 104 | margin: 9px 0; 105 | } 106 | input[type='checkbox'] { 107 | width: 16px; 108 | height: 16px; 109 | accent-color: #0061e0; 110 | } 111 | .uv { 112 | font-size: 0.75em; 113 | position: relative; 114 | display: inline-block; 115 | } 116 | 117 | .uv .close { 118 | font-size: 30px; 119 | text-align: center; 120 | background-color: #eeeeee; 121 | position: absolute; 122 | top: 0; 123 | right: 0; 124 | cursor: pointer; 125 | width: 50px; 126 | height: 45px; 127 | border-radius: 13px; 128 | } 129 | 130 | .uv-container { 131 | display: grid; 132 | grid-template-columns: 25% 1fr; 133 | align-items: center; 134 | } 135 | .uv h3 { 136 | margin-top: 0; 137 | } 138 | .uv-text { 139 | padding-left: 10px; 140 | } 141 | a { 142 | color: rgb(0, 96, 223); 143 | } 144 | 145 | nav { 146 | display: flex; 147 | flex-wrap: wrap; 148 | width: 100%; 149 | position: sticky; 150 | top: 0; 151 | padding: 0 40px; 152 | background-color: #ffffff; 153 | } 154 | 155 | .uv input { 156 | background-color: #eeeeee; 157 | border: 1px solid transparent; 158 | padding: 7px; 159 | border-radius: 4px; 160 | color: #6a196b; 161 | } 162 | 163 | #permissions-request, 164 | #permissions-request button { 165 | cursor: pointer; 166 | } 167 | 168 | #documentation img { 169 | border: 10px solid #1c1c1c; 170 | border-radius: 10px; 171 | padding-right: auto; 172 | padding-left: auto; 173 | } 174 | 175 | body.permissions-granted .permissions-request { 176 | display: none; 177 | } 178 | 179 | @media (prefers-color-scheme: dark) { 180 | h2 { 181 | border-top: 1px solid #5e5e5e; 182 | } 183 | body { 184 | background-color: #23222b; 185 | color: #fbfbfe; 186 | } 187 | 188 | a { 189 | color: rgb(0, 221, 255); 190 | } 191 | 192 | button, 193 | .button { 194 | background-color: #2c2b32; 195 | color: #fbfbfe; 196 | } 197 | input[type='checkbox'] { 198 | accent-color: #00ddff; 199 | } 200 | 201 | .section { 202 | background-color: #2f2e36; 203 | } 204 | 205 | .section button, 206 | .uv .close { 207 | background-color: #3e3d44; 208 | } 209 | button:active, 210 | button:hover, 211 | .uv .close:active, 212 | .uv .close:hover { 213 | background-color: #4d4c52; 214 | } 215 | .section button.primary:active, 216 | .section .button.primary:active, 217 | .section button.primary:hover, 218 | .section .button.primary:hover { 219 | background-color: #80ebff; 220 | } 221 | 222 | button.primary, 223 | .button.primary { 224 | background-color: #00ddff; 225 | color: #292f38; 226 | } 227 | button:focus, 228 | .button:focus { 229 | outline: 2px solid #00ddff; 230 | } 231 | button.primary:active, 232 | .button.primary:active, 233 | button.primary:hover, 234 | .button.primary:active { 235 | background-color: #80ebff; 236 | } 237 | 238 | .warning { 239 | background-color: #16424e; 240 | border: 1px solid #00ddff; 241 | } 242 | .uv input { 243 | background-color: #3e3d44; 244 | border: 1px solid transparent; 245 | padding: 7px; 246 | border-radius: 4px; 247 | color: #d0a3d1; 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /src/xhr.ts: -------------------------------------------------------------------------------- 1 | import { EXTENSION_ID } from './constants'; 2 | 3 | const oldXHROpen = window.XMLHttpRequest.prototype.open; 4 | 5 | window.XMLHttpRequest.prototype.open = function (method, url) { 6 | if (method === 'GET' && typeof url === 'string') { 7 | if (url.includes('/api/v1/feed/reels_media/?reel_ids=')) { 8 | this.addEventListener('load', function () { 9 | chrome.runtime.sendMessage(EXTENSION_ID, { data: this.responseText, api: '/api/v1/feed/reels_media/?reel_ids=' }); 10 | }); 11 | } 12 | } 13 | 14 | if (method === 'POST') { 15 | switch (url) { 16 | case '/ajax/bulk-route-definitions/': 17 | case 'https://www.instagram.com/ajax/bulk-route-definitions/': 18 | this.addEventListener('load', async function () { 19 | try { 20 | const { 21 | payload: { payloads }, 22 | } = JSON.parse(this.responseText.split(/\s*for\s+\(;;\);\s*/)[1]); 23 | for (const [key, value] of Object.entries(payloads)) { 24 | if (key.startsWith('/stories/')) { 25 | await chrome.runtime.sendMessage(EXTENSION_ID, { 26 | type: 'stories', 27 | data: { 28 | username: key.split('/')[2], 29 | // @ts-expect-error value is unknown 30 | user_id: value.result.exports.rootView.props.user_id, 31 | }, 32 | }); 33 | } 34 | } 35 | } catch {} 36 | }); 37 | break; 38 | case '/ajax/route-definition/': 39 | case 'https://www.threads.com/ajax/route-definition/': 40 | this.addEventListener('load', function () { 41 | chrome.runtime.sendMessage(EXTENSION_ID, { 42 | type: 'threads_searchResults', 43 | data: this.responseText, 44 | }); 45 | }); 46 | break; 47 | case '/graphql/query': 48 | case 'https://www.instagram.com/graphql/query': 49 | this.addEventListener('load', function () { 50 | chrome.runtime.sendMessage(EXTENSION_ID, { api: 'https://www.instagram.com/graphql/query', data: this.responseText }); 51 | 52 | try { 53 | const data = JSON.parse(this.responseText); 54 | // Threads 55 | if (Array.isArray(data.data?.feedData?.edges)) { 56 | chrome.runtime.sendMessage(EXTENSION_ID, { 57 | type: 'threads', 58 | data: data.data.feedData.edges 59 | .map( 60 | (i: any) => 61 | i.node?.text_post_app_thread?.thread_items || i.node?.thread_items || i.text_post_app_thread?.thread_items 62 | ) 63 | .flat(), 64 | }); 65 | } 66 | if (Array.isArray(data.data?.mediaData?.edges)) { 67 | chrome.runtime.sendMessage(EXTENSION_ID, { 68 | type: 'threads', 69 | data: data.data.mediaData.edges.map((i: any) => i.node.thread_items).flat(), 70 | }); 71 | } 72 | if (Array.isArray(data.data?.data?.edges)) { 73 | chrome.runtime.sendMessage(EXTENSION_ID, { 74 | type: 'threads', 75 | data: data.data.data.edges.map((i: any) => i.node.thread_items).flat(), 76 | }); 77 | } 78 | if (Array.isArray(data.data?.results?.edges)) { 79 | chrome.runtime.sendMessage(EXTENSION_ID, { 80 | type: 'threads', 81 | data: data.data.results.edges.map((i: any) => i.node.thread_items).flat(), 82 | }); 83 | } 84 | if (typeof data.data?.replyPost === 'object') { 85 | chrome.runtime.sendMessage(EXTENSION_ID, { 86 | type: 'threads', 87 | data: [data.data.replyPost], 88 | }); 89 | } 90 | if (Array.isArray(data.data?.searchResults?.edges)) { 91 | chrome.runtime.sendMessage(EXTENSION_ID, { 92 | type: 'threads', 93 | data: data.data.searchResults.edges.map((i: any) => i.node.thread.thread_items).flat(), 94 | }); 95 | } 96 | } catch (error) { 97 | console.log(error); 98 | } 99 | }); 100 | break; 101 | case 'https://www.instagram.com/api/graphql': 102 | case 'https://www.threads.com/graphql/query': 103 | case '/api/graphql': 104 | this.addEventListener('load', function () { 105 | chrome.runtime.sendMessage(EXTENSION_ID, { api: 'https://www.instagram.com/api/graphql', data: this.responseText }); 106 | }); 107 | break; 108 | default: 109 | break; 110 | } 111 | } 112 | 113 | // eslint-disable-next-line prefer-rest-params 114 | return oldXHROpen.apply(this, [].slice.call(arguments) as any); 115 | }; 116 | -------------------------------------------------------------------------------- /src/content/highlights.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import {checkType, DownloadParams, downloadResource, getMediaName, openInNewTab} from './utils/fn'; 3 | import type {Highlight} from '../types/highlights'; 4 | import type {ReelsMedia} from '../types/global'; 5 | 6 | function getSectionNode(target: HTMLAnchorElement) { 7 | let sectionNode: HTMLElement = target; 8 | while (sectionNode.tagName !== 'SECTION' && sectionNode.parentElement) { 9 | sectionNode = sectionNode.parentElement; 10 | } 11 | return sectionNode; 12 | } 13 | 14 | function findHighlight(obj: Record): Highlight.XdtApiV1FeedReelsMediaConnection | undefined { 15 | for (const key in obj) { 16 | if (key === 'xdt_api__v1__feed__reels_media__connection') { 17 | return obj[key]; 18 | } else if (typeof obj[key] === 'object' && obj[key] !== null) { 19 | const result = findHighlight(obj[key]); 20 | if (result) { 21 | return result; 22 | } 23 | } 24 | } 25 | } 26 | 27 | export async function highlightsOnClicked(target: HTMLAnchorElement) { 28 | const sectionNode = getSectionNode(target); 29 | const pathname = window.location.pathname; // "/stories/highlights/18023929792378379/" 30 | const pathnameArr = pathname.split('/'); 31 | const { 32 | setting_format_use_indexing, 33 | } = await chrome.storage.sync.get(['setting_format_use_indexing']); 34 | 35 | const final = (url: string, filenameObj?: Omit) => { 36 | if (target.className.includes('download-btn')) { 37 | if (filenameObj) { 38 | downloadResource({ 39 | url: url, 40 | ...filenameObj, 41 | }); 42 | } else { 43 | let posterName = 'highlights'; 44 | for (const item of sectionNode.querySelectorAll('a[role=link]')) { 45 | const hrefArr = item 46 | .getAttribute('href') 47 | ?.split('/') 48 | .filter((_) => _); 49 | if (hrefArr?.length === 1) { 50 | posterName = hrefArr[1]; 51 | break; 52 | } 53 | } 54 | const postTime = [...sectionNode.querySelectorAll('time')].find((i) => i.classList.length !== 0)?.getAttribute('datetime'); 55 | downloadResource({ 56 | url: url, 57 | username: posterName, 58 | datetime: postTime, 59 | fileId: getMediaName(url), 60 | }); 61 | } 62 | } else { 63 | openInNewTab(url); 64 | } 65 | }; 66 | 67 | let mediaIndex = 0; 68 | 69 | const handleMedias = (data: Highlight.Node) => { 70 | const media = data.items[mediaIndex]; 71 | const url = media.video_versions?.[0].url || media.image_versions2.candidates[0].url; 72 | final(url, { 73 | username: data.user.username, 74 | datetime: dayjs.unix(media.taken_at), 75 | fileId: setting_format_use_indexing ? `${data.id}_${mediaIndex + 1}` : getMediaName(url) 76 | }); 77 | }; 78 | 79 | target.parentElement?.firstElementChild?.querySelectorAll(':scope>div').forEach((i, idx) => { 80 | if (i.childNodes.length === 1) { 81 | mediaIndex = idx; 82 | } 83 | }); 84 | 85 | // profile page highlight on Android 86 | if (checkType() === 'android') { 87 | sectionNode.querySelectorAll('header>div:nth-child(1)>div').forEach((item, index) => { 88 | item.querySelectorAll('div').forEach((i) => { 89 | if (i.classList.length === 2) { 90 | mediaIndex = index; 91 | } 92 | }); 93 | }); 94 | const {reels_media} = await chrome.storage.local.get(['reels_media']); 95 | const itemOnAndroid = (reels_media || []).find((i: ReelsMedia.ReelsMedum) => i.id === 'highlight:' + pathnameArr[3]); 96 | if (itemOnAndroid) { 97 | handleMedias(itemOnAndroid); 98 | return; 99 | } 100 | for (const item of sectionNode.querySelectorAll('img')) { 101 | if (item.srcset !== '') { 102 | final(item.src); 103 | return; 104 | } 105 | } 106 | } 107 | 108 | const {highlights_data} = await chrome.storage.local.get(['highlights_data']); 109 | const localData = new Map(highlights_data).get('highlight:' + pathnameArr[3]) as Highlight.Node | undefined; 110 | if (localData) { 111 | handleMedias(localData); 112 | return; 113 | } 114 | 115 | for (const script of window.document.scripts) { 116 | try { 117 | const innerHTML = script.innerHTML; 118 | const data = JSON.parse(innerHTML); 119 | if (innerHTML.includes('xdt_api__v1__feed__reels_media__connection')) { 120 | const res = findHighlight(data); 121 | if (res) { 122 | handleMedias(res.edges[0].node); 123 | return; 124 | } 125 | } 126 | } catch { 127 | } 128 | } 129 | 130 | const videoUrl = sectionNode.querySelector('video')?.getAttribute('src'); 131 | if (videoUrl) { 132 | final(videoUrl); 133 | return; 134 | } 135 | 136 | for (const item of sectionNode.querySelectorAll('img[referrerpolicy="origin-when-cross-origin"]')) { 137 | if (item.classList.length > 1) { 138 | final(item.src); 139 | return; 140 | } 141 | } 142 | 143 | alert('download highlights failed!'); 144 | } 145 | -------------------------------------------------------------------------------- /src/popup/index.scss: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | @font-face { 8 | font-family: 'Segoe UI Variable'; 9 | src: url('https://fonts.cdnfonts.com/css/segoe-ui-variable') format('woff2'); 10 | } 11 | 12 | body { 13 | font-family: 'Segoe UI Variable', -apple-system, BlinkMacSystemFont, sans-serif; 14 | } 15 | 16 | html, 17 | body { 18 | overflow-x: hidden; 19 | } 20 | 21 | button { 22 | border: 0; 23 | outline: 0; 24 | } 25 | 26 | .container { 27 | overflow: hidden; 28 | width: 420px; 29 | padding: 1.5rem; 30 | background-color: khaki; 31 | background-image: repeating-linear-gradient(45deg, transparent, transparent 2rem, rgba(0, 0, 0, 0.02) 2rem, rgba(0, 0, 0, 0.02) 4rem); 32 | 33 | &.mobile { 34 | width: 100vw; 35 | padding: 1rem; 36 | } 37 | 38 | h2 { 39 | margin: 0.6rem 0; 40 | } 41 | 42 | > .github-bg { 43 | z-index: 10; 44 | position: fixed; 45 | right: -60px; 46 | top: -60px; 47 | background: #181717; 48 | transform: rotate(45deg); 49 | width: 120px; 50 | aspect-ratio: 1; 51 | } 52 | 53 | > .github { 54 | z-index: 100; 55 | position: fixed; 56 | right: 6px; 57 | top: 6px; 58 | cursor: pointer; 59 | width: 36px; 60 | height: 36px; 61 | transition: all 0.3s; 62 | 63 | &:hover { 64 | transform: scale(1.1); 65 | filter: invert(100%); 66 | } 67 | } 68 | } 69 | 70 | .settings { 71 | position: relative; 72 | width: 100%; 73 | display: flex; 74 | flex-direction: column; 75 | 76 | .setting { 77 | position: relative; 78 | width: 100%; 79 | background-color: hsl(200deg, 72.5%, 50%); 80 | border-radius: 20px; 81 | display: flex; 82 | align-items: center; 83 | padding: 12px 20px; 84 | color: #fff; 85 | margin-bottom: 8px; 86 | font-size: 1rem; 87 | padding-right: 50px; 88 | 89 | input { 90 | opacity: 0; 91 | position: absolute; 92 | + label { 93 | user-select: none; 94 | &::before, 95 | &::after { 96 | content: ''; 97 | position: absolute; 98 | transition: 150ms cubic-bezier(0.24, 0, 0.5, 1); 99 | transform: translateY(-50%); 100 | top: 50%; 101 | right: 10px; 102 | cursor: pointer; 103 | } 104 | &::before { 105 | height: 30px; 106 | width: 50px; 107 | border-radius: 30px; 108 | background: rgba(214, 214, 214, 0.434); 109 | } 110 | &::after { 111 | height: 24px; 112 | width: 24px; 113 | border-radius: 60px; 114 | right: 32px; 115 | background: #fff; 116 | } 117 | } 118 | &:checked { 119 | & + label:before { 120 | background: #5d68e2; 121 | transition: all 150ms cubic-bezier(0, 0, 0, 0.1); 122 | } 123 | & + label:after { 124 | right: 14px; 125 | } 126 | } 127 | &:focus { 128 | + label:before { 129 | box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.75); 130 | } 131 | } 132 | &:disabled { 133 | + label { 134 | &:before, 135 | &:after { 136 | cursor: not-allowed; 137 | } 138 | &:before { 139 | background: #4f4f6a; 140 | } 141 | &:after { 142 | background: #909090; 143 | } 144 | } 145 | } 146 | } 147 | } 148 | } 149 | 150 | .group { 151 | position: relative; 152 | margin-top: 20px; 153 | margin-bottom: 8px; 154 | 155 | input { 156 | font-size: 18px; 157 | padding: 10px 10px 10px 5px; 158 | display: block; 159 | width: 360px; 160 | border: none; 161 | border-bottom: 1px solid #757575; 162 | } 163 | input:focus { 164 | outline: none; 165 | } 166 | 167 | label { 168 | color: #999; 169 | font-size: 18px; 170 | font-weight: normal; 171 | position: absolute; 172 | pointer-events: none; 173 | left: 5px; 174 | top: 10px; 175 | transition: 0.2s ease all; 176 | -moz-transition: 0.2s ease all; 177 | -webkit-transition: 0.2s ease all; 178 | } 179 | 180 | input:focus ~ label, 181 | input:valid ~ label { 182 | top: -20px; 183 | font-size: 14px; 184 | color: #5264ae; 185 | } 186 | 187 | .bar { 188 | position: relative; 189 | display: block; 190 | width: 360px; 191 | } 192 | .bar:before, 193 | .bar:after { 194 | content: ''; 195 | height: 2px; 196 | width: 0; 197 | bottom: 1px; 198 | position: absolute; 199 | background: #5264ae; 200 | transition: 0.2s ease all; 201 | -moz-transition: 0.2s ease all; 202 | -webkit-transition: 0.2s ease all; 203 | } 204 | .bar:before { 205 | left: 50%; 206 | } 207 | .bar:after { 208 | right: 50%; 209 | } 210 | 211 | input:focus ~ .bar:before, 212 | input:focus ~ .bar:after { 213 | width: 50%; 214 | } 215 | 216 | .highlight { 217 | position: absolute; 218 | height: 60%; 219 | width: 100px; 220 | top: 25%; 221 | left: 0; 222 | pointer-events: none; 223 | opacity: 0.5; 224 | } 225 | 226 | input:focus ~ .highlight { 227 | animation: inputHighlighter 0.3s ease; 228 | } 229 | } 230 | 231 | @keyframes inputHighlighter { 232 | from { 233 | background: #5264ae; 234 | } 235 | to { 236 | width: 0; 237 | background: transparent; 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /src/content/utils/zip.ts: -------------------------------------------------------------------------------- 1 | import {BlobReader, BlobWriter, ZipWriter} from "@zip.js/zip.js"; 2 | import dayjs from "dayjs"; 3 | import {DEFAULT_DATETIME_FORMAT, MESSAGE_ZIP_DOWNLOAD} from "../../constants"; 4 | import {getDataFromAPI, getFilenameFromUrl, getImgOrVideoUrl} from "./fn"; 5 | 6 | async function handleZipFirefox(articleNode: HTMLElement) { 7 | const data = await getDataFromAPI(articleNode); 8 | const blobList = []; 9 | if ('carousel_media' in data) { 10 | const list = await Promise.all( 11 | data.carousel_media.map(async (resource: any, index: number) => { 12 | const url = getImgOrVideoUrl(resource); 13 | const filename = await getFilenameFromUrl({ 14 | url: url, 15 | username: resource.owner?.username || data.owner.username, 16 | datetime: dayjs.unix(resource.taken_at), 17 | fileId: resource.pk, 18 | }); 19 | const response = await fetch(url, { 20 | headers: new Headers({ 21 | Origin: location.origin, 22 | }), 23 | mode: 'cors', 24 | }); 25 | if (!response.ok) { 26 | console.error(`Failed to fetch ${url}`); 27 | return null; 28 | } 29 | const content = await response.blob(); 30 | return {filename: `${(index + 1).toString().padStart(2, '0')}-${filename}`, content}; 31 | }) 32 | ); 33 | blobList.push(...list.filter((e) => e)); 34 | } else { 35 | const url = getImgOrVideoUrl(data); 36 | const response = await fetch(url, { 37 | headers: new Headers({ 38 | Origin: location.origin, 39 | }), 40 | mode: 'cors', 41 | }); 42 | if (!response.ok) { 43 | console.error(`Failed to fetch ${url}`); 44 | return; 45 | } 46 | const filename = await getFilenameFromUrl({ 47 | url: url, 48 | username: data.owner.username, 49 | datetime: dayjs.unix(data.taken_at), 50 | fileId: data.code || data.id, 51 | }); 52 | const content = await response.blob(); 53 | blobList.push({filename, content}); 54 | } 55 | const {setting_format_datetime = DEFAULT_DATETIME_FORMAT} = await chrome.storage.sync.get(['setting_format_datetime']); 56 | chrome.runtime.sendMessage({ 57 | type: MESSAGE_ZIP_DOWNLOAD, 58 | data: { 59 | blobList, 60 | zipFileName: [ 61 | data.owner.username, data.code, dayjs.unix(data.taken_at).format(setting_format_datetime) 62 | ].join('_'), 63 | }, 64 | }); 65 | return; 66 | } 67 | 68 | async function handleZipChrome(articleNode: HTMLElement) { 69 | const data = await getDataFromAPI(articleNode); 70 | const zipFileWriter = new BlobWriter(); 71 | const zipWriter = new ZipWriter(zipFileWriter); 72 | const {setting_format_replace_jpeg_with_jpg} = await chrome.storage.sync.get(['setting_format_replace_jpeg_with_jpg']); 73 | if ('carousel_media' in data) { 74 | for (let i = 0; i < data.carousel_media.length; i++) { 75 | const resource = data.carousel_media[i]; 76 | const url = getImgOrVideoUrl(resource); 77 | const response = await fetch(url, { 78 | headers: new Headers({ 79 | Origin: location.origin, 80 | }), 81 | mode: 'cors', 82 | }); 83 | if (!response.ok) { 84 | console.error(`Failed to fetch ${url}`); 85 | continue; 86 | } 87 | const content = await response.blob(); 88 | const filename = await getFilenameFromUrl({ 89 | url: url, 90 | username: resource.owner?.username || data.owner.username, 91 | datetime: dayjs.unix(resource.taken_at), 92 | fileId: resource.pk, 93 | }); 94 | let extension = content.type.split('/').pop() || 'jpg'; 95 | if (setting_format_replace_jpeg_with_jpg) { 96 | extension = extension.replace('jpeg', 'jpg'); 97 | } 98 | await zipWriter.add( 99 | `${(i + 1).toString().padStart(2, '0')}-${filename}.${extension}`, 100 | new BlobReader(content), {useWebWorkers: false} 101 | ); 102 | } 103 | } else { 104 | const url = getImgOrVideoUrl(data); 105 | const response = await fetch(url, { 106 | headers: new Headers({ 107 | Origin: location.origin, 108 | }), 109 | mode: 'cors', 110 | }); 111 | if (!response.ok) { 112 | console.error(`Failed to fetch ${url}`); 113 | return; 114 | } 115 | const filename = await getFilenameFromUrl({ 116 | url: url, 117 | username: data.owner.username, 118 | datetime: dayjs.unix(data.taken_at), 119 | fileId: data.code || data.id, 120 | }); 121 | const content = await response.blob(); 122 | let extension = content.type.split('/').pop() || 'jpg'; 123 | if (setting_format_replace_jpeg_with_jpg) { 124 | extension = extension.replace('jpeg', 'jpg'); 125 | } 126 | await zipWriter.add(filename + '.' + extension, new BlobReader(content), { 127 | useWebWorkers: false, 128 | }); 129 | } 130 | 131 | const zipContent = await zipWriter.close(); 132 | const blobUrl = URL.createObjectURL(zipContent); 133 | const a = document.createElement('a'); 134 | a.href = blobUrl; 135 | const {setting_format_datetime = DEFAULT_DATETIME_FORMAT} = await chrome.storage.sync.get(['setting_format_datetime']); 136 | a.download = [ 137 | data.owner.username, data.code, dayjs.unix(data.taken_at).format(setting_format_datetime) 138 | ].join('_') + '.zip'; 139 | document.body.appendChild(a); 140 | a.click(); 141 | 142 | setTimeout(() => { 143 | document.body.removeChild(a); 144 | URL.revokeObjectURL(blobUrl); 145 | }, 100); 146 | 147 | return; 148 | } 149 | 150 | 151 | export function handleZipDownload(articleNode: HTMLElement) { 152 | return typeof browser !== 'undefined' ? handleZipFirefox(articleNode) : handleZipChrome(articleNode); 153 | } -------------------------------------------------------------------------------- /src/content/threads/post.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import {downloadResource, getMediaName, openInNewTab} from '../utils/fn'; 3 | 4 | function findFeedDataEdges(obj: Record): Array> | null { 5 | if (obj) { 6 | if (Array.isArray(obj.edges)) { 7 | return obj.edges; 8 | } 9 | if (Array.isArray(obj.relatedPosts?.threads)) { 10 | return obj.relatedPosts.threads; 11 | } 12 | } 13 | 14 | for (const key in obj) { 15 | if (typeof obj[key] === 'object') { 16 | const result = findFeedDataEdges(obj[key]); 17 | if (result) { 18 | return result; 19 | } 20 | } else if (Array.isArray(obj[key])) { 21 | for (const item of obj[key]) { 22 | const result = findFeedDataEdges(item); 23 | if (result) { 24 | return result; 25 | } 26 | } 27 | } 28 | } 29 | 30 | return null; 31 | } 32 | 33 | function handleMedia(post: any, action: 'download' | 'open') { 34 | const {giphy_media_info, carousel_media, image_versions2, video_versions, text_post_app_info} = post; 35 | if (giphy_media_info?.first_party_cdn_proxied_images?.fixed_height?.webp) { 36 | const url = giphy_media_info?.first_party_cdn_proxied_images?.fixed_height?.webp; 37 | if (action === 'download') { 38 | downloadResource({ 39 | url: url, 40 | username: post.user.username, 41 | datetime: dayjs.unix(post.taken_at), 42 | fileId: getMediaName(url), 43 | }); 44 | } else { 45 | openInNewTab(url); 46 | } 47 | } 48 | if (Array.isArray(carousel_media) && carousel_media.length > 0) { 49 | carousel_media.forEach((item: any) => { 50 | const url = item.video_versions?.[0]?.url || item.image_versions2?.candidates?.[0]?.url; 51 | console.log('url', post, url); 52 | if (!url) return; 53 | if (action === 'download') { 54 | downloadResource({ 55 | url: url, 56 | username: post.user.username, 57 | datetime: dayjs.unix(post.taken_at), 58 | fileId: getMediaName(url), 59 | }); 60 | } else { 61 | openInNewTab(url); 62 | } 63 | }); 64 | } else { 65 | const url = video_versions?.[0]?.url || image_versions2?.candidates?.[0]?.url; 66 | if (url) { 67 | console.log('url', post, url); 68 | if (action === 'download') { 69 | downloadResource({ 70 | url: url, 71 | username: post.user.username, 72 | datetime: dayjs.unix(post.taken_at), 73 | fileId: getMediaName(url), 74 | }); 75 | } else { 76 | openInNewTab(url); 77 | } 78 | } else { 79 | const data = text_post_app_info?.linked_inline_media; 80 | if (data && Array.isArray(data.video_versions)) { 81 | const url = data.video_versions[0]?.url; 82 | if (!url) return; 83 | if (action === 'download') { 84 | downloadResource({ 85 | url: url, 86 | username: post.user.username, 87 | datetime: dayjs.unix(post.taken_at), 88 | fileId: getMediaName(url), 89 | }); 90 | } else { 91 | openInNewTab(url); 92 | } 93 | } else if (data && Array.isArray(data.carousel_media)) { 94 | data.carousel_media.forEach((item: any) => { 95 | const url = item.video_versions?.[0]?.url || item.image_versions2?.candidates?.[0]?.url; 96 | console.log('url', post, url); 97 | if (!url) return; 98 | if (action === 'download') { 99 | downloadResource({ 100 | url: url, 101 | username: post.user.username, 102 | datetime: dayjs.unix(post.taken_at), 103 | fileId: getMediaName(url), 104 | }); 105 | } else { 106 | openInNewTab(url); 107 | } 108 | }); 109 | } 110 | } 111 | } 112 | } 113 | 114 | export async function handleThreadsPost(container: HTMLDivElement, action: 'download' | 'open') { 115 | const postCode = [...container.querySelectorAll('a')].find((i) => /\w+\/post\/\w+/.test(i.href))?.href.split('/post/')[1]; 116 | const {threads} = await chrome.storage.local.get(['threads']); 117 | const data = new Map(threads); 118 | const thread = data.get(postCode) as Record | undefined; 119 | 120 | if (thread) { 121 | handleMedia(thread.post || thread, action); 122 | return; 123 | } else { 124 | for (const script of window.document.scripts) { 125 | try { 126 | const innerHTML = script.innerHTML; 127 | const data = JSON.parse(innerHTML); 128 | if (innerHTML.includes('thread_items')) { 129 | const arr = findFeedDataEdges(data); 130 | 131 | if (Array.isArray(arr)) { 132 | const data = arr 133 | .map( 134 | (i) => 135 | i.node?.text_post_app_thread?.thread_items || 136 | i.node?.thread_items || 137 | i.node?.thread?.thread_items || 138 | i.text_post_app_thread?.thread_items || 139 | i.thread_items 140 | ) 141 | .flat() 142 | .find((i: Record | undefined) => i?.post.code === postCode); 143 | 144 | if (data) { 145 | const {post} = data; 146 | handleMedia(post, action); 147 | return; 148 | } 149 | } 150 | } 151 | } catch { 152 | } 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | export type IconColor = 'white' | 'black'; 2 | export type IconClassName = 'newtab-btn' | 'download-btn' | 'zip-btn'; 3 | 4 | declare module 'react' { 5 | interface CSSProperties { 6 | [key: `--${string}`]: string | number; 7 | } 8 | } 9 | 10 | declare namespace ReelsMedia { 11 | export interface Root { 12 | reels: Record; 13 | reels_media: ReelsMedum[]; 14 | status: string; 15 | } 16 | 17 | export interface User { 18 | pk: string; 19 | pk_id: string; 20 | full_name: string; 21 | is_private: boolean; 22 | strong_id__: string; 23 | username: string; 24 | is_verified: boolean; 25 | profile_pic_id?: string; 26 | profile_pic_url: string; 27 | friendship_status: FriendshipStatus; 28 | interop_messaging_user_fbid: number; 29 | } 30 | 31 | export interface FriendshipStatus { 32 | following: boolean; 33 | is_private: boolean; 34 | incoming_request: boolean; 35 | outgoing_request: boolean; 36 | is_bestie: boolean; 37 | is_restricted: boolean; 38 | is_feed_favorite: boolean; 39 | } 40 | 41 | export interface Item { 42 | taken_at: number; 43 | pk: string; 44 | id: string; 45 | caption_position: number; 46 | is_reel_media: boolean; 47 | is_terminal_video_segment: boolean; 48 | device_timestamp: number; 49 | client_cache_key: string; 50 | filter_type: number; 51 | caption_is_edited: boolean; 52 | like_and_view_counts_disabled: boolean; 53 | strong_id__: string; 54 | is_reshare_of_text_post_app_media_in_ig: boolean; 55 | is_post_live_clips_media: boolean; 56 | imported_taken_at?: number; 57 | deleted_reason: number; 58 | integrity_review_decision: string; 59 | has_shared_to_fb: number; 60 | expiring_at: number; 61 | is_unified_video: boolean; 62 | should_request_ads: boolean; 63 | is_visual_reply_commenter_notice_enabled: boolean; 64 | commerciality_status: string; 65 | explore_hide_comments: boolean; 66 | shop_routing_user_id: any; 67 | can_see_insights_as_brand: boolean; 68 | is_organic_product_tagging_eligible: boolean; 69 | likers: any[]; 70 | media_type: number; 71 | code: string; 72 | caption: any; 73 | clips_tab_pinned_user_ids: any[]; 74 | comment_inform_treatment: CommentInformTreatment; 75 | sharing_friction_info: SharingFrictionInfo; 76 | has_translation: boolean; 77 | accessibility_caption?: string; 78 | original_media_has_visual_reply_media: boolean; 79 | fb_user_tags: FbUserTags; 80 | invited_coauthor_producers: any[]; 81 | can_viewer_save: boolean; 82 | is_in_profile_grid: boolean; 83 | profile_grid_control_enabled: boolean; 84 | is_comments_gif_composer_enabled: boolean; 85 | product_suggestions: any[]; 86 | attribution_content_url?: string; 87 | image_versions2: ImageVersions2; 88 | original_width: number; 89 | original_height: number; 90 | enable_media_notes_production: boolean; 91 | product_type: string; 92 | is_paid_partnership: boolean; 93 | music_metadata: any; 94 | organic_tracking_token: string; 95 | ig_media_sharing_disabled: boolean; 96 | boost_unavailable_identifier: any; 97 | boost_unavailable_reason: any; 98 | open_carousel_submission_state: string; 99 | is_open_to_public_submission: boolean; 100 | has_delayed_metadata: boolean; 101 | is_auto_created: boolean; 102 | is_cutout_sticker_allowed: boolean; 103 | owner: Owner; 104 | is_dash_eligible?: number; 105 | video_dash_manifest?: string; 106 | video_codec?: string; 107 | number_of_qualities?: number; 108 | video_versions?: VideoVersion[]; 109 | video_duration?: number; 110 | has_audio?: boolean; 111 | user: Owner; 112 | can_reshare: boolean; 113 | can_reply: boolean; 114 | can_send_prompt: boolean; 115 | is_first_take: boolean; 116 | is_rollcall_v2: boolean; 117 | is_superlative: boolean; 118 | is_fb_post_from_fb_story: boolean; 119 | can_play_spotify_audio: boolean; 120 | archive_story_deletion_ts: number; 121 | should_render_soundwave: boolean; 122 | created_from_add_yours_browsing: boolean; 123 | story_feed_media?: StoryFeedMedum[]; 124 | has_liked: boolean; 125 | supports_reel_reactions: boolean; 126 | can_send_custom_emojis: boolean; 127 | show_one_tap_fb_share_tooltip: boolean; 128 | story_bloks_stickers?: StoryBloksSticker[]; 129 | } 130 | 131 | export interface CommentInformTreatment { 132 | should_have_inform_treatment: boolean; 133 | text: string; 134 | url: any; 135 | action_type: any; 136 | } 137 | 138 | export interface SharingFrictionInfo { 139 | should_have_sharing_friction: boolean; 140 | bloks_app_url: any; 141 | sharing_friction_payload: any; 142 | } 143 | 144 | export interface FbUserTags { 145 | in: any[]; 146 | } 147 | 148 | export interface ImageVersions2 { 149 | candidates: Candidate[]; 150 | } 151 | 152 | export interface Candidate { 153 | width: number; 154 | height: number; 155 | url: string; 156 | } 157 | 158 | export interface Owner { 159 | pk: string; 160 | is_private: boolean; 161 | } 162 | 163 | export interface VideoVersion { 164 | type: number; 165 | width: number; 166 | height: number; 167 | url: string; 168 | id: string; 169 | } 170 | 171 | export interface StoryFeedMedum { 172 | x: number; 173 | y: number; 174 | z: number; 175 | width: number; 176 | height: number; 177 | rotation: number; 178 | is_pinned: number; 179 | is_hidden: number; 180 | is_sticker: number; 181 | is_fb_sticker: number; 182 | start_time_ms: number; 183 | end_time_ms: number; 184 | media_id: string; 185 | product_type: string; 186 | media_code: string; 187 | media_compound_str: string; 188 | clips_creation_entry_point: string; 189 | } 190 | 191 | export interface StoryBloksSticker { 192 | bloks_sticker: BloksSticker; 193 | x: number; 194 | y: number; 195 | z: number; 196 | width: number; 197 | height: number; 198 | rotation: number; 199 | } 200 | 201 | export interface BloksSticker { 202 | id: string; 203 | app_id: string; 204 | sticker_data: StickerData; 205 | bloks_sticker_type: string; 206 | } 207 | 208 | export interface StickerData { 209 | ig_mention: IgMention; 210 | } 211 | 212 | export interface IgMention { 213 | account_id: string; 214 | username: string; 215 | full_name: string; 216 | profile_pic_url: string; 217 | } 218 | 219 | export interface ReelsMedum { 220 | id: string; 221 | strong_id__: string; 222 | latest_reel_media: number; 223 | expiring_at: number; 224 | seen: number; 225 | can_reply: boolean; 226 | can_gif_quick_reply: boolean; 227 | can_reshare: boolean; 228 | can_react_with_avatar: boolean; 229 | reel_type: string; 230 | ad_expiry_timestamp_in_millis: any; 231 | is_cta_sticker_available: any; 232 | app_sticker_info: any; 233 | should_treat_link_sticker_as_cta: any; 234 | user: User; 235 | items: Item[]; 236 | prefetch_count: number; 237 | media_count: number; 238 | media_ids: string[]; 239 | is_cacheable: boolean; 240 | disabled_reply_types: string[]; 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /src/content/post-detail.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import {checkType, downloadResource, getMediaName, getUrlFromInfoApi, openInNewTab} from './utils/fn'; 3 | 4 | 5 | async function fetchVideoURL(containerNode: HTMLElement, videoElem: HTMLVideoElement) { 6 | const poster = videoElem.getAttribute('poster'); 7 | const timeNodes = containerNode.querySelectorAll('time'); 8 | const posterUrl = (timeNodes[timeNodes.length - 1].parentNode!.parentNode as any).href; 9 | const posterPattern = /\/([^/?]*)\?/; 10 | const posterMatch = poster?.match(posterPattern); 11 | const postFileName = posterMatch?.[1]; 12 | const resp = await fetch(posterUrl); 13 | const content = await resp.text(); 14 | const pattern = new RegExp(`${postFileName}.*?video_versions.*?url":("[^"]*")`, 's'); 15 | const match = content.match(pattern); 16 | let videoUrl = JSON.parse(match?.[1] ?? ''); 17 | videoUrl = videoUrl.replace(/^(?:https?:\/\/)?(?:[^@/\n]+@)?(?:www\.)?([^:/?\n]+)/g, 'https://scontent.cdninstagram.com'); 18 | videoElem.setAttribute('videoURL', videoUrl); 19 | return videoUrl; 20 | } 21 | 22 | const getVideoSrc = async (containerNode: HTMLElement, videoElem: HTMLVideoElement) => { 23 | let url = videoElem.getAttribute('src'); 24 | if (videoElem.hasAttribute('videoURL')) { 25 | url = videoElem.getAttribute('videoURL'); 26 | } else if (url === null || url.includes('blob')) { 27 | url = await fetchVideoURL(containerNode, videoElem); 28 | } 29 | return url; 30 | }; 31 | 32 | async function getUrl(containerNode: HTMLElement) { 33 | const pathnameList = window.location.pathname.split('/').filter((e) => e); 34 | const isPostDetailWithNameInUrl = pathnameList.length === 3 && pathnameList[1] === 'p'; 35 | 36 | const mediaList = containerNode.querySelectorAll('li[style][class]'); 37 | 38 | let url, res; 39 | let mediaIndex = -1; 40 | 41 | if (mediaList.length === 0) { 42 | // single img or video 43 | res = await getUrlFromInfoApi(containerNode); 44 | url = res?.url; 45 | if (!url) { 46 | const videoElem: HTMLVideoElement | null = containerNode.querySelector('article div > video'); 47 | const imgElem = containerNode.querySelector('article div[role] div > img'); 48 | if (videoElem) { 49 | // media type is video 50 | if (videoElem) { 51 | url = await getVideoSrc(containerNode, videoElem); 52 | } 53 | } else if (imgElem) { 54 | // media type is image 55 | url = imgElem.getAttribute('src'); 56 | } else { 57 | console.log('Err: not find media at handle post single'); 58 | } 59 | } 60 | } else { 61 | // multiple media 62 | const idxFromUrl = new URLSearchParams(window.location.search).get('img_index'); 63 | if (idxFromUrl) { 64 | mediaIndex = +idxFromUrl - 1 65 | } else { 66 | let dotsList; 67 | if (checkType() === 'pc') { 68 | dotsList = isPostDetailWithNameInUrl 69 | ? containerNode.querySelectorAll('article>div>div:nth-child(1)>div>div:nth-child(2)>div') 70 | : containerNode.querySelectorAll('div[role=button]>div>div>div>div>div>div:nth-child(2)>div'); 71 | } else { 72 | dotsList = containerNode.querySelectorAll(`article>div>div:nth-child(2)>div>div:nth-child(2)>div`); 73 | } 74 | mediaIndex = [...dotsList].findIndex((i) => i.classList.length === 2); 75 | if (mediaIndex == -1) { 76 | console.warn("No media index found."); 77 | mediaIndex = 0 78 | } 79 | } 80 | 81 | res = await getUrlFromInfoApi(containerNode, mediaIndex); 82 | url = res?.url; 83 | if (!url) { 84 | const listElements = [ 85 | ...containerNode.querySelectorAll( 86 | `:scope > div > div:nth-child(1) > div > div:nth-child(1) ul li[style*="translateX"]` 87 | ), 88 | ]; 89 | const listElementWidth = Math.max(...listElements.map((element) => element.clientWidth)); 90 | const positionsMap = listElements.reduce>((result, element) => { 91 | const position = Math.round(Number(element.style.transform.match(/-?(\d+)/)?.[1]) / listElementWidth); 92 | return {...result, [position]: element}; 93 | }, {}); 94 | 95 | const node = positionsMap[mediaIndex]; 96 | const videoElem = node.querySelector('video'); 97 | const imgElem = node.querySelector('img'); 98 | if (videoElem) { 99 | // media type is video 100 | url = await getVideoSrc(containerNode, videoElem); 101 | } else if (imgElem) { 102 | // media type is image 103 | url = imgElem.getAttribute('src'); 104 | } 105 | } 106 | } 107 | return {url, res, mediaIndex}; 108 | } 109 | 110 | export async function postDetailOnClicked(target: HTMLAnchorElement) { 111 | const containerNode = document.querySelector('section main'); 112 | if (!containerNode) return; 113 | 114 | const {setting_format_use_indexing} = await chrome.storage.sync.get(['setting_format_use_indexing']); 115 | try { 116 | if (target.className.includes('zip-btn')) { 117 | const {handleZipDownload} = await import("./utils/zip") 118 | return handleZipDownload(containerNode) 119 | } 120 | 121 | const data = await getUrl(containerNode); 122 | if (!data?.url) throw new Error('Cannot get url'); 123 | 124 | const {url, res, mediaIndex} = data; 125 | console.log('url', url); 126 | if (target.className.includes('download-btn')) { 127 | let postTime, posterName, fileId; 128 | if (res) { 129 | posterName = res.owner; 130 | postTime = dayjs.unix(res.taken_at); 131 | fileId = res.origin_data?.id || getMediaName(url); 132 | } else { 133 | postTime = document.querySelector('time')?.getAttribute('datetime'); 134 | const name = document.querySelector( 135 | 'section main>div>div>div>div:nth-child(2)>div>div>div>div:nth-child(2)>div>div>div' 136 | ); 137 | if (name) { 138 | posterName = name.innerText || posterName; 139 | } 140 | } 141 | if (mediaIndex !== undefined && mediaIndex >= 0) { 142 | fileId = `${fileId}_${mediaIndex + 1}`; 143 | } 144 | // if setting_format_use_indexing is disabled (by setting it to false), then we need to overwrite the fileId to getMediaName(url). 145 | // Otherwise, the fileId could be the res.origin_data?.id without indexing, and multiple media from the same post could yield 146 | // to same filename when indexing is disabled. 147 | if (!setting_format_use_indexing) { 148 | fileId = getMediaName(url); 149 | } 150 | downloadResource({ 151 | url: url, 152 | username: posterName, 153 | datetime: dayjs(postTime), 154 | fileId: fileId || getMediaName(url), 155 | }); 156 | } else { 157 | openInNewTab(url); 158 | } 159 | } catch (e: any) { 160 | alert('Post Detail Download Failed!'); 161 | console.log(`Uncaught in postDetailOnClicked(): ${e}\n${e.stack}`); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/content/stories.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import {downloadResource, getMediaName, getUrlFromInfoApi, openInNewTab} from './utils/fn'; 3 | import type {Stories} from '../types/stories'; 4 | import type {ReelsMedia} from '../types/global'; 5 | import {getParentSectionNode} from "./utils/dom"; 6 | 7 | async function storyGetUrl(target: HTMLElement, sectionNode: any) { 8 | const res = await getUrlFromInfoApi(target); 9 | let url = res?.url; 10 | if (!url) { 11 | if (sectionNode.querySelector('video > source')) { 12 | url = sectionNode.querySelector('video > source').getAttribute('src'); 13 | } else if (sectionNode.querySelector('img[decoding="sync"]')) { 14 | const img = sectionNode.querySelector('img[decoding="sync"]'); 15 | url = img.srcset.split(/ \d+w/g)[0].trim(); // extract first src from srcset attr. of img 16 | if (url && url.length > 0) { 17 | return url; 18 | } 19 | url = sectionNode.querySelector('img[decoding="sync"]').getAttribute('src'); 20 | } else if (sectionNode.querySelector('video')) { 21 | url = sectionNode.querySelector('video').getAttribute('src'); 22 | } 23 | } 24 | return url; 25 | } 26 | 27 | // 递归搜索包含 rootView 的对象 28 | function findRootView(obj: Record): Record | undefined { 29 | for (const key in obj) { 30 | if (key === 'rootView') { 31 | return obj[key]; 32 | } else if (typeof obj[key] === 'object' && obj[key] !== null) { 33 | const result = findRootView(obj[key]); 34 | if (result) { 35 | return result; 36 | } 37 | } 38 | } 39 | } 40 | 41 | function findStories(obj: Record): Stories.XdtApiV1FeedReelsMedia | undefined { 42 | for (const key in obj) { 43 | if (key === 'xdt_api__v1__feed__reels_media') { 44 | return obj[key]; 45 | } else if (typeof obj[key] === 'object' && obj[key] !== null) { 46 | const result = findStories(obj[key]); 47 | if (result) { 48 | return result; 49 | } 50 | } 51 | } 52 | } 53 | 54 | export async function storyOnClicked(target: HTMLAnchorElement) { 55 | const pathname = window.location.pathname; 56 | const pathnameArr = pathname.split('/').filter((e) => e); 57 | const posterName = pathnameArr[1]; 58 | const {setting_format_use_indexing} = await chrome.storage.sync.get(['setting_format_use_indexing']); 59 | 60 | const handleMedia = (item: Stories.ReelsMedum, mediaIndex: number) => { 61 | const media = item.items[mediaIndex]; 62 | if (!media) return false; 63 | if (dayjs.unix(media.expiring_at).isBefore(dayjs())) { 64 | return false; 65 | } 66 | const url = media.video_versions?.[0].url || media.image_versions2.candidates[0].url; 67 | if (target.className.includes('download-btn')) { 68 | downloadResource({ 69 | url: url, 70 | username: item.user.username, 71 | datetime: dayjs.unix(media.taken_at), 72 | fileId: setting_format_use_indexing ? `${item.id}_${mediaIndex + 1}` : getMediaName(url), 73 | }); 74 | } else { 75 | openInNewTab(url); 76 | } 77 | return true; 78 | }; 79 | 80 | const {stories_reels_media} = await chrome.storage.local.get(['stories_reels_media']); 81 | const stories_reels_media_data: Map = new Map(stories_reels_media); 82 | 83 | // no media_id in url 84 | if (pathnameArr.length === 2) { 85 | let mediaIndex = 0; 86 | const steps = target.parentElement!.firstElementChild!.querySelectorAll(':scope>div'); 87 | // multiple media, find the media index 88 | if (steps.length > 1) { 89 | steps.forEach((item, index) => { 90 | if (item.childNodes.length === 1) { 91 | mediaIndex = index; 92 | } 93 | }); 94 | } 95 | 96 | // when open the page from an empty tab, data return with html but not xhr 97 | if (window.history.length <= 2) { 98 | for (const script of window.document.scripts) { 99 | try { 100 | const innerHTML = script.innerHTML; 101 | const data = JSON.parse(innerHTML); 102 | if (innerHTML.includes('xdt_api__v1__feed__reels_media')) { 103 | const res = findStories(data); 104 | if (res) { 105 | handleMedia(res.reels_media[0], mediaIndex); 106 | return; 107 | } 108 | } 109 | } catch { 110 | } 111 | } 112 | } 113 | 114 | const {stories_user_ids} = await chrome.storage.local.get(['stories_user_ids']); 115 | const user_id = new Map(stories_user_ids).get(posterName); 116 | if (typeof user_id === 'string') { 117 | const item = stories_reels_media_data.get(user_id); 118 | if (item && steps.length === item.items.length) { 119 | const result = handleMedia(item, mediaIndex); 120 | if (result) return; 121 | } 122 | } 123 | 124 | for (const script of window.document.scripts) { 125 | try { 126 | const innerHTML = script.innerHTML; 127 | const data = JSON.parse(innerHTML); 128 | if (innerHTML.includes('rootView')) { 129 | const rootViewData = findRootView(data); 130 | const id = rootViewData?.props.media_owner_id || rootViewData?.props.id; 131 | const item = stories_reels_media_data.get(id); 132 | if (item) { 133 | handleMedia(item, mediaIndex); 134 | return; 135 | } 136 | } 137 | } catch { 138 | } 139 | } 140 | } else { 141 | const mediaId = pathnameArr.at(-1)!; 142 | 143 | for (const item of [...stories_reels_media_data.values()]) { 144 | for (let i = 0; i < item.items.length; i++) { 145 | if (item.items[i].pk === mediaId) { 146 | const result = handleMedia(item, i); 147 | if (result) return; 148 | } 149 | } 150 | } 151 | 152 | for (const script of window.document.scripts) { 153 | try { 154 | const innerHTML = script.innerHTML; 155 | const data = JSON.parse(innerHTML); 156 | if (innerHTML.includes('xdt_api__v1__feed__reels_media')) { 157 | const res = findStories(data); 158 | if (res) { 159 | handleMedia( 160 | res.reels_media[0], 161 | res.reels_media[0].items.findIndex((i) => i.pk === mediaId) 162 | ); 163 | return; 164 | } 165 | } 166 | } catch { 167 | } 168 | } 169 | 170 | const {reels_media} = await chrome.storage.local.get(['reels_media']); 171 | const item = (reels_media || []).find((i: ReelsMedia.ReelsMedum) => i.media_ids?.includes(mediaId)); 172 | if (item) { 173 | handleMedia(item, item.media_ids.indexOf(mediaId)); 174 | return; 175 | } 176 | const sectionNode = getParentSectionNode(target); 177 | if (!sectionNode) return; 178 | const url = await storyGetUrl(target, sectionNode); 179 | if (url) { 180 | const postTime = sectionNode.querySelector('time')?.getAttribute('datetime'); 181 | if (target.className.includes('download-btn')) { 182 | downloadResource({ 183 | url: url, 184 | username: posterName, 185 | datetime: dayjs(postTime), 186 | fileId: getMediaName(url), 187 | }); 188 | } else { 189 | openInNewTab(url); 190 | } 191 | } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/popup/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import './index.scss'; 4 | 5 | import { CONFIG_LIST, DEFAULT_DATETIME_FORMAT, DEFAULT_FILENAME_FORMAT } from '../constants'; 6 | import SettingItem from './SettingItem'; 7 | 8 | function App() { 9 | const [newTab, setNewTab] = useState(true); 10 | const [threads, setThreads] = useState(true); 11 | const [enableVideoControl, setEnableVideoControl] = useState(true); 12 | const [replaceJpegWithJpg, setReplaceJpegWithJpg] = useState(true); 13 | const [useHashId, setUseHashId] = useState(false); 14 | const [useIndexing, setUseIndexing] = useState(true); 15 | const [enableDatetimeFormat, setEnableDatetimeFormat] = useState(true); 16 | const [enableZipDownload, setEnableZipDownload] = useState(true); 17 | 18 | const [fileNameFormat, setFileNameFormat] = useState(DEFAULT_FILENAME_FORMAT); 19 | const [dateTimeFormat, setDateTimeFormat] = useState(DEFAULT_DATETIME_FORMAT); 20 | 21 | const isMobile = navigator && navigator.userAgent && /Mobi|Android|iPhone/i.test(navigator.userAgent); 22 | 23 | useEffect(() => { 24 | chrome.storage.sync.get(CONFIG_LIST).then((res) => { 25 | setNewTab(!!res.setting_show_open_in_new_tab_icon); 26 | setThreads(!!res.setting_enable_threads); 27 | setEnableVideoControl(!!res.setting_enable_video_controls); 28 | setReplaceJpegWithJpg(!!res.setting_format_replace_jpeg_with_jpg); 29 | setUseHashId(!!res.setting_format_use_hash_id); 30 | setUseIndexing(!!res.setting_format_use_indexing); 31 | setEnableDatetimeFormat(!!res.setting_enable_datetime_format); 32 | setEnableZipDownload(!!res.setting_show_zip_download_icon); 33 | }); 34 | 35 | chrome.storage.sync.get(['setting_format_filename', 'setting_format_datetime']).then((res) => { 36 | setFileNameFormat(res.setting_format_filename || DEFAULT_FILENAME_FORMAT); 37 | setDateTimeFormat(res.setting_format_datetime || DEFAULT_DATETIME_FORMAT); 38 | }); 39 | }, []); 40 | 41 | // When useHashId is true, it will hash the fileId, so the indexing will be meaningless. 42 | // Therefore, when useHashId is true, we should set the useIndexing to false. 43 | // But, when useHashId is false, the useIndexing can be true or false. 44 | const handleUseHashIdChange = () => { 45 | if (useHashId) { 46 | setUseIndexing(false); 47 | chrome.storage.sync.set({ setting_format_use_indexing: false }); 48 | } 49 | }; 50 | 51 | const handleUseIndexingChange = () => { 52 | if (useHashId) { 53 | setUseIndexing(false); 54 | chrome.storage.sync.set({ setting_format_use_indexing: false }); 55 | } 56 | }; 57 | useEffect(() => { 58 | if (useHashId) { 59 | setUseIndexing(false); 60 | chrome.storage.sync.set({ setting_format_use_indexing: false }); 61 | } 62 | }, [useHashId]); 63 | 64 | return ( 65 | <> 66 |
67 | 73 | 74 | 75 | 76 | 80 | 81 | 82 | 83 |
84 | 85 |
86 |

Icon Settings

87 | 93 | 99 | 100 |

Download File Name Settings

101 | 107 | 114 | 115 | 122 | 123 |
124 | { 128 | setFileNameFormat(e.target.value); 129 | chrome.storage.sync.set({ setting_format_filename: e.target.value || DEFAULT_FILENAME_FORMAT }); 130 | }} 131 | /> 132 | 133 | 134 | 135 |
136 | 137 | 143 | 144 | {enableDatetimeFormat && ( 145 |
146 | { 150 | setDateTimeFormat(e.target.value); 151 | chrome.storage.sync.set({ setting_format_datetime: e.target.value || DEFAULT_DATETIME_FORMAT }); 152 | }} 153 | /> 154 | 155 | 156 | 157 |
158 | )} 159 | 160 |

Video Settings

161 | 167 | 168 |

Threads Settings

169 | 170 |
171 |
172 | 173 | ); 174 | } 175 | 176 | createRoot(document.getElementById('root')!).render( 177 | 178 | 179 | 180 | ); 181 | -------------------------------------------------------------------------------- /src/content/post.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import {checkType, downloadResource, getMediaName, getUrlFromInfoApi, openInNewTab,} from './utils/fn'; 3 | import {getParentArticleNode} from "./utils/dom"; 4 | import {handleZipDownload} from "./utils/zip"; 5 | 6 | 7 | async function fetchVideoURL(articleNode: HTMLElement, videoElem: HTMLVideoElement) { 8 | const poster = videoElem.getAttribute('poster'); 9 | const timeNodes = articleNode.querySelectorAll('time'); 10 | const posterUrl = (timeNodes[timeNodes.length - 1].parentNode!.parentNode as any).href; 11 | const posterPattern = /\/([^/?]*)\?/; 12 | const posterMatch = poster?.match(posterPattern); 13 | const postFileName = posterMatch?.[1]; 14 | const resp = await fetch(posterUrl); 15 | const content = await resp.text(); 16 | const pattern = new RegExp(`${postFileName}.*?video_versions.*?url":("[^"]*")`, 's'); 17 | const match = content.match(pattern); 18 | let videoUrl = JSON.parse(match?.[1] ?? ''); 19 | videoUrl = videoUrl.replace(/^(?:https?:\/\/)?(?:[^@/\n]+@)?(?:www\.)?([^:/?\n]+)/g, 'https://scontent.cdninstagram.com'); 20 | videoElem.setAttribute('videoURL', videoUrl); 21 | return videoUrl; 22 | } 23 | 24 | const getVideoSrc = async (articleNode: HTMLElement, videoElem: HTMLVideoElement) => { 25 | let url = videoElem.getAttribute('src'); 26 | if (videoElem.hasAttribute('videoURL')) { 27 | url = videoElem.getAttribute('videoURL'); 28 | } else if (url === null || url.includes('blob')) { 29 | url = await fetchVideoURL(articleNode, videoElem); 30 | } 31 | return url; 32 | }; 33 | 34 | async function postGetUrl(articleNode: HTMLElement) { 35 | let url, res; 36 | let mediaIndex = -1; 37 | 38 | if (articleNode.querySelectorAll('li[style][class]').length === 0) { 39 | // single img or video 40 | res = await getUrlFromInfoApi(articleNode); 41 | url = res?.url; 42 | if (!url) { 43 | const videoElem = articleNode.querySelector('article div > video'); 44 | const imgElem = articleNode.querySelector('article div[role] div > img'); 45 | if (videoElem) { 46 | // media type is video 47 | if (videoElem) { 48 | url = await getVideoSrc(articleNode, videoElem); 49 | } 50 | } else if (imgElem) { 51 | // media type is image 52 | url = imgElem.getAttribute('src'); 53 | } else { 54 | console.log('Err: not find media at handle post single'); 55 | } 56 | } 57 | } else { 58 | // multiple media 59 | const isPostView = window.location.pathname.startsWith('/p/'); 60 | const idxFromUrl = new URLSearchParams(window.location.search).get('img_index'); 61 | if (idxFromUrl) { 62 | mediaIndex = +idxFromUrl - 1 63 | } else { 64 | let dotsList: any 65 | if (isPostView) { 66 | dotsList = articleNode.querySelectorAll(`:scope>div>div:nth-child(1)>div>div>div:nth-child(2)>div`); 67 | } else { 68 | if (checkType() === 'pc') { 69 | dotsList = 70 | articleNode.querySelector('ul')?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement 71 | ?.nextElementSibling?.childNodes || [] 72 | } else { 73 | dotsList = articleNode.querySelectorAll(`:scope > div > div:nth-child(2) > div>div>div>div>div>div:nth-child(2)>div`); 74 | } 75 | } 76 | // if get dots list fail, try get img url from img element attribute 77 | if (dotsList.length === 0) { 78 | const imgList = articleNode.querySelectorAll(`${isPostView ? ':scope>div>div:nth-child(1)' : ''} li img`); 79 | const {x, right} = articleNode.getBoundingClientRect(); 80 | for (const item of [...imgList]) { 81 | const rect = item.getBoundingClientRect(); 82 | if (rect.x > x && rect.right < right) { 83 | url = item.getAttribute('src'); 84 | return {url}; 85 | } 86 | } 87 | return null; 88 | } 89 | mediaIndex = [...dotsList].findIndex((i) => i.classList.length === 2); 90 | if (mediaIndex == -1) { 91 | console.warn("No media index found."); 92 | mediaIndex = 0 93 | } 94 | } 95 | res = await getUrlFromInfoApi(articleNode, mediaIndex); 96 | url = res?.url; 97 | if (!url) { 98 | const listElements = [ 99 | ...articleNode.querySelectorAll( 100 | `:scope > div > div:nth-child(${isPostView ? 1 : 2}) > div > div:nth-child(1) ul li[style*="translateX"]` 101 | ), 102 | ] as HTMLLIElement[]; 103 | const listElementWidth = Math.max(...listElements.map((element) => element.clientWidth)); 104 | const positionsMap = listElements.reduce>((result, element) => { 105 | const position = Math.round(Number(element.style.transform.match(/-?(\d+)/)?.[1]) / listElementWidth); 106 | return {...result, [position]: element}; 107 | }, {}); 108 | 109 | const node = positionsMap[mediaIndex]; 110 | const videoElem = node.querySelector('video'); 111 | const imgElem = node.querySelector('img'); 112 | if (videoElem) { 113 | // media type is video 114 | url = await getVideoSrc(articleNode, videoElem); 115 | } else if (imgElem) { 116 | // media type is image 117 | url = imgElem.getAttribute('src'); 118 | } 119 | } 120 | } 121 | return {url, res, mediaIndex}; 122 | } 123 | 124 | export async function postOnClicked(target: HTMLAnchorElement) { 125 | const {setting_format_use_indexing} = await chrome.storage.sync.get(['setting_format_use_indexing']); 126 | try { 127 | const articleNode = getParentArticleNode(target); 128 | if (!articleNode) throw new Error('Cannot find article node'); 129 | 130 | if (target.className.includes('zip-btn')) { 131 | const {handleZipDownload} = await import("./utils/zip") 132 | return handleZipDownload(articleNode) 133 | } 134 | 135 | const data = await postGetUrl(articleNode); 136 | if (!data?.url) throw new Error('Cannot get url'); 137 | const {url, res, mediaIndex} = data; 138 | console.log('post url=', url); 139 | if (target.className.includes('download-btn')) { 140 | let postTime, posterName, fileId; 141 | if (res) { 142 | posterName = res.owner; 143 | postTime = dayjs.unix(res.taken_at); 144 | fileId = res.origin_data?.id || getMediaName(url); 145 | } else { 146 | postTime = articleNode.querySelector('time')?.getAttribute('datetime'); 147 | posterName = articleNode.querySelector('a')?.getAttribute('href')?.replace(/\//g, ''); 148 | const tagNode = document.querySelector( 149 | 'path[d="M21.334 23H2.666a1 1 0 0 1-1-1v-1.354a6.279 6.279 0 0 1 6.272-6.272h8.124a6.279 6.279 0 0 1 6.271 6.271V22a1 1 0 0 1-1 1ZM12 13.269a6 6 0 1 1 6-6 6.007 6.007 0 0 1-6 6Z"]' 150 | ); 151 | if (tagNode) { 152 | const name = document.querySelector('article header>div:nth-child(2) span'); 153 | if (name) { 154 | posterName = name.innerText || posterName; 155 | } 156 | } 157 | } 158 | if (mediaIndex !== undefined && mediaIndex >= 0) { 159 | fileId = `${fileId}_${mediaIndex + 1}`; 160 | } 161 | // if setting_format_use_indexing is disabled (by setting it to false), then we need to overwrite the fileId to getMediaName(url). 162 | // Otherwise, the fileId could be the res.origin_data?.id without indexing, and multiple media from the same post could yield 163 | // to same filename when indexing is disabled. 164 | if (!setting_format_use_indexing) { 165 | fileId = getMediaName(url); 166 | } 167 | downloadResource({ 168 | url: url, 169 | username: posterName, 170 | datetime: dayjs(postTime), 171 | fileId: fileId || getMediaName(url), 172 | }); 173 | } else { 174 | openInNewTab(url); 175 | } 176 | } catch (e: any) { 177 | alert('post get media failed!'); 178 | console.log(`Uncaught in postOnClicked(): ${e}\n${e.stack}`); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/content/profile-reel.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import {checkType, DownloadParams, downloadResource, getMediaName, getUrlFromInfoApi, openInNewTab} from './utils/fn'; 3 | import {ProfileReel} from '../types/profileReel'; 4 | 5 | async function fetchVideoURL(containerNode: HTMLElement, videoElem: HTMLVideoElement) { 6 | const poster = videoElem.getAttribute('poster'); 7 | const timeNodes = containerNode.querySelectorAll('time'); 8 | const posterUrl = (timeNodes[timeNodes.length - 1].parentNode!.parentNode as any).href; 9 | const posterPattern = /\/([^/?]*)\?/; 10 | const posterMatch = poster?.match(posterPattern); 11 | const postFileName = posterMatch?.[1]; 12 | const resp = await fetch(posterUrl); 13 | const content = await resp.text(); 14 | const pattern = new RegExp(`${postFileName}.*?video_versions.*?url":("[^"]*")`, 's'); 15 | const match = content.match(pattern); 16 | let videoUrl = JSON.parse(match?.[1] ?? ''); 17 | videoUrl = videoUrl.replace(/^(?:https?:\/\/)?(?:[^@/\n]+@)?(?:www\.)?([^:/?\n]+)/g, 'https://scontent.cdninstagram.com'); 18 | videoElem.setAttribute('videoURL', videoUrl); 19 | return videoUrl; 20 | } 21 | 22 | const getVideoSrc = async (containerNode: HTMLElement, videoElem: HTMLVideoElement) => { 23 | let url = videoElem.getAttribute('src'); 24 | if (videoElem.hasAttribute('videoURL')) { 25 | url = videoElem.getAttribute('videoURL'); 26 | } else if (url === null || url.includes('blob')) { 27 | url = await fetchVideoURL(containerNode, videoElem); 28 | } 29 | return url; 30 | }; 31 | 32 | async function getUrl() { 33 | const containerNode = document.querySelector('section main'); 34 | if (!containerNode) return; 35 | 36 | const pathnameList = window.location.pathname.split('/').filter((e) => e); 37 | const isPostDetailWithNameInUrl = pathnameList.length === 3 && pathnameList[1] === 'p'; 38 | 39 | const mediaList = containerNode.querySelectorAll('li[style][class]'); 40 | 41 | let url, res; 42 | if (mediaList.length === 0) { 43 | // single img or video 44 | res = await getUrlFromInfoApi(containerNode); 45 | url = res?.url; 46 | if (!url) { 47 | const videoElem: HTMLVideoElement | null = containerNode.querySelector('article div > video'); 48 | const imgElem = containerNode.querySelector('article div[role] div > img'); 49 | if (videoElem) { 50 | // media type is video 51 | if (videoElem) { 52 | url = await getVideoSrc(containerNode, videoElem); 53 | } 54 | } else if (imgElem) { 55 | // media type is image 56 | url = imgElem.getAttribute('src'); 57 | } else { 58 | console.log('Err: not find media at handle post single'); 59 | } 60 | } 61 | } else { 62 | // multiple media 63 | let dotsList; 64 | if (checkType() === 'pc') { 65 | dotsList = isPostDetailWithNameInUrl 66 | ? containerNode.querySelectorAll('article>div>div:nth-child(1)>div>div:nth-child(2)>div') 67 | : containerNode.querySelectorAll('div[role=button]>div>div>div>div:nth-child(2)>div'); 68 | } else { 69 | dotsList = containerNode.querySelectorAll(`div[role=button][aria-hidden="true"][tabindex="0"]>div>div>div>div:nth-child(2)>div`); 70 | } 71 | const mediaIndex = [...dotsList].findIndex((i) => i.classList.length === 2); 72 | res = await getUrlFromInfoApi(containerNode, mediaIndex); 73 | url = res?.url; 74 | if (!url) { 75 | const listElements = [ 76 | ...containerNode.querySelectorAll( 77 | `:scope > div > div:nth-child(1) > div > div:nth-child(1) ul li[style*="translateX"]` 78 | ), 79 | ]; 80 | const listElementWidth = Math.max(...listElements.map((element) => element.clientWidth)); 81 | const positionsMap = listElements.reduce>((result, element) => { 82 | const position = Math.round(Number(element.style.transform.match(/-?(\d+)/)?.[1]) / listElementWidth); 83 | return {...result, [position]: element}; 84 | }, {}); 85 | 86 | const node = positionsMap[mediaIndex]; 87 | const videoElem = node.querySelector('video'); 88 | const imgElem = node.querySelector('img'); 89 | if (videoElem) { 90 | // media type is video 91 | url = await getVideoSrc(containerNode, videoElem); 92 | } else if (imgElem) { 93 | // media type is image 94 | url = imgElem.getAttribute('src'); 95 | } 96 | } 97 | } 98 | return {url, res}; 99 | } 100 | 101 | export async function handleProfileReel(target: HTMLAnchorElement) { 102 | const code = window.location.pathname.split('/').at(-2); 103 | 104 | const final = (obj: DownloadParams) => { 105 | if (target.className.includes('download-btn')) { 106 | downloadResource(obj); 107 | } else { 108 | openInNewTab(obj.url); 109 | } 110 | }; 111 | 112 | async function getDataFromLocal() { 113 | const {profile_reels_edges_data, id_to_username_map} = await chrome.storage.local.get([ 114 | 'profile_reels_edges_data', 115 | 'id_to_username_map', 116 | ]); 117 | 118 | const media = new Map(profile_reels_edges_data).get(code) as ProfileReel.Media | undefined; 119 | if (media) { 120 | const url = media.video_versions?.[0].url || media.image_versions2.candidates[0].url; 121 | const times = target.parentElement?.parentElement?.parentElement?.querySelectorAll('time'); 122 | const time = times ? times[times.length - 1]?.getAttribute('datetime') : undefined; 123 | final({ 124 | url: url, 125 | username: 126 | (new Map(id_to_username_map).get(media.user.id) as string) || 127 | document.querySelector('a')?.getAttribute('href')?.replace(/\//g, ''), 128 | datetime: time ? dayjs(time) : undefined, 129 | fileId: getMediaName(url), 130 | }); 131 | return true; 132 | } 133 | } 134 | 135 | async function getDataFromScripts() { 136 | function findReel(obj: Record): any { 137 | for (const key in obj) { 138 | if (key === 'xdt_api__v1__media__shortcode__web_info') { 139 | return obj[key]; 140 | } else if (typeof obj[key] === 'object' && obj[key] !== null) { 141 | const result = findReel(obj[key]); 142 | if (result) { 143 | return result; 144 | } 145 | } 146 | } 147 | } 148 | 149 | for (const script of [...window.document.scripts]) { 150 | try { 151 | const innerHTML = script.innerHTML; 152 | const data = JSON.parse(innerHTML); 153 | if (innerHTML.includes('xdt_api__v1__media__shortcode__web_info')) { 154 | const res = findReel(data); 155 | if (res) { 156 | for (const media of res.items) { 157 | if (media.code === code) { 158 | const url = media.video_versions?.[0].url || media.image_versions2.candidates[0].url; 159 | final({ 160 | url: url, 161 | username: media.user.username, 162 | datetime: dayjs.unix(media.taken_at), 163 | fileId: getMediaName(url), 164 | }); 165 | return; 166 | } 167 | } 168 | } 169 | } 170 | } catch { 171 | } 172 | } 173 | } 174 | 175 | try { 176 | const data = await getUrl(); 177 | if (!data?.url) throw new Error('Cannot get url'); 178 | 179 | const {url, res} = data; 180 | console.log('url', url); 181 | if (target.className.includes('download-btn')) { 182 | let postTime, posterName; 183 | if (res) { 184 | posterName = res.owner; 185 | postTime = res.taken_at * 1000; 186 | } else { 187 | postTime = document.querySelector('time')?.getAttribute('datetime'); 188 | const name = document.querySelector( 189 | 'section main>div>div>div>div:nth-child(2)>div>div>div>div:nth-child(2)>div>div>div' 190 | ); 191 | if (name) { 192 | posterName = name.innerText || posterName; 193 | } 194 | } 195 | downloadResource({ 196 | url: url, 197 | username: posterName, 198 | datetime: dayjs(postTime), 199 | fileId: getMediaName(url), 200 | }); 201 | } else { 202 | openInNewTab(url); 203 | } 204 | } catch { 205 | const res = await getDataFromLocal(); 206 | if (res !== true) { 207 | if (!document.querySelector('div[role=dialog]')) { 208 | getDataFromScripts(); 209 | } else { 210 | alert('profile reel get media failed!'); 211 | } 212 | } 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/content/button.ts: -------------------------------------------------------------------------------- 1 | import {CLASS_CUSTOM_BUTTON} from '../constants'; 2 | import type {IconClassName, IconColor} from '../types/global'; 3 | import {highlightsOnClicked} from './highlights'; 4 | import {postOnClicked} from './post'; 5 | import {postDetailOnClicked} from './post-detail'; 6 | import {profileOnClicked} from './profile'; 7 | import {handleProfileReel} from './profile-reel'; 8 | import {reelsOnClicked} from './reels'; 9 | import {storyOnClicked} from './stories'; 10 | import {handleThreadsButton} from './threads/button'; 11 | import {checkType, downloadResource} from './utils/fn'; 12 | 13 | const svgDownloadBtn = ` 15 | 16 | 19 | 20 | 21 | 25 | 26 | `; 27 | 28 | const svgNewtabBtn = ` 29 | 30 | `; 31 | 32 | const svgZipBtn = ` 33 | 34 | File Zip Light Streamline Icon: https://streamlinehq.com 35 | 36 | 37 | `; 38 | 39 | export function onClickHandler(currentTarget: Element) { 40 | if (currentTarget instanceof HTMLAnchorElement) { 41 | if (window.location.origin === 'https://www.threads.com') { 42 | handleThreadsButton(currentTarget); 43 | return; 44 | } 45 | 46 | const pathPrefix = window.location.pathname; 47 | const pathnameList = pathPrefix.split('/').filter((e) => e); 48 | const isPostDetailWithNameInUrl = pathnameList.length === 3 && pathnameList[1] === 'p'; 49 | const isReelDetailWithNameInUrl = pathnameList.length === 3 && pathnameList[1] === 'reel'; 50 | 51 | let fn: (target: HTMLAnchorElement) => Promise = postOnClicked; 52 | if (document.querySelector('section>main>div>header>section:nth-child(2)')?.contains(currentTarget)) { 53 | fn = profileOnClicked; 54 | } else if (pathPrefix.startsWith('/reels/')) { 55 | fn = reelsOnClicked; 56 | } else if (pathPrefix.startsWith('/stories/highlights/')) { 57 | fn = highlightsOnClicked; 58 | } else if (pathPrefix.startsWith('/stories/')) { 59 | fn = storyOnClicked; 60 | } else if (pathPrefix.startsWith('/reel/')) { 61 | fn = handleProfileReel; 62 | } else if (pathPrefix.startsWith('/p/')) { 63 | if (document.querySelector('div[role="dialog"]')) { 64 | fn = postOnClicked; 65 | } else { 66 | fn = postDetailOnClicked; 67 | } 68 | } else if (isPostDetailWithNameInUrl || isReelDetailWithNameInUrl) { 69 | fn = postDetailOnClicked; 70 | } 71 | 72 | fn(currentTarget); 73 | } 74 | } 75 | 76 | function createCustomBtn(svg: string, iconColor: IconColor, className: IconClassName) { 77 | const newBtn = document.createElement('a'); 78 | newBtn.innerHTML = svg; 79 | newBtn.className = CLASS_CUSTOM_BUTTON + ' ' + className; 80 | newBtn.setAttribute('style', `cursor: pointer;padding:8px;z-index: 0;display:inline-flex;color:${iconColor}`); 81 | newBtn.onmouseenter = () => { 82 | newBtn.style.setProperty('filter', 'drop-shadow(0px 0px 10px deepskyblue)'); 83 | }; 84 | newBtn.onmouseleave = () => { 85 | newBtn.style.removeProperty('filter'); 86 | }; 87 | switch (className) { 88 | case 'newtab-btn': 89 | newBtn.setAttribute('title', 'Open In New Tab'); 90 | newBtn.setAttribute('target', '_blank'); 91 | newBtn.setAttribute('rel', 'noopener,noreferrer'); 92 | break; 93 | case "download-btn": 94 | newBtn.setAttribute('title', 'Download'); 95 | break; 96 | case "zip-btn": 97 | newBtn.setAttribute('title', 'Download ZIP'); 98 | break; 99 | } 100 | return newBtn; 101 | } 102 | 103 | export async function addCustomBtn(node: any, iconColor: IconColor, position: 'before' | 'after' = 'after') { 104 | const {setting_show_open_in_new_tab_icon, setting_show_zip_download_icon} = await chrome.storage.sync.get([ 105 | 'setting_show_open_in_new_tab_icon', 106 | 'setting_show_zip_download_icon', 107 | ]); 108 | const downloadBtn = createCustomBtn(svgDownloadBtn, iconColor, 'download-btn'); 109 | let newtabBtn, zipBtn; 110 | if (!(checkType() !== 'pc' && window.location.pathname.startsWith('/stories/'))) { 111 | if (setting_show_open_in_new_tab_icon) { 112 | newtabBtn = createCustomBtn(svgNewtabBtn, iconColor, 'newtab-btn'); 113 | } 114 | } 115 | if ( 116 | setting_show_zip_download_icon && 117 | window.location.host === 'www.instagram.com' && 118 | !window.location.pathname.startsWith('/reel') && 119 | !window.location.pathname.startsWith('/stories/') 120 | ) { 121 | zipBtn = createCustomBtn(svgZipBtn, iconColor, 'zip-btn'); 122 | } 123 | if (position === 'before') { 124 | if (newtabBtn) { 125 | node.insertBefore(newtabBtn, node.firstChild); 126 | } 127 | node.insertBefore(downloadBtn, node.firstChild); 128 | if (zipBtn) { 129 | node.insertBefore(zipBtn, node.firstChild); 130 | } 131 | } else { 132 | if (newtabBtn) { 133 | node.appendChild(newtabBtn); 134 | } 135 | node.appendChild(downloadBtn); 136 | if (zipBtn) { 137 | node.appendChild(zipBtn); 138 | } 139 | } 140 | } 141 | 142 | export function addVideoDownloadCoverBtn(node: HTMLDivElement) { 143 | const newBtn = document.createElement('a'); 144 | newBtn.innerHTML = svgDownloadBtn; 145 | newBtn.className = CLASS_CUSTOM_BUTTON; 146 | newBtn.setAttribute('style', 'cursor: pointer;position:absolute;left:4px;top:4px;color:white'); 147 | newBtn.setAttribute('title', 'Download Video Cover'); 148 | newBtn.dataset.videoCoverDownload = "true"; 149 | newBtn.onmouseenter = () => { 150 | newBtn.style.setProperty('scale', '1.1'); 151 | }; 152 | newBtn.onmouseleave = () => { 153 | newBtn.style.removeProperty('scale'); 154 | }; 155 | node.appendChild(newBtn); 156 | } 157 | 158 | 159 | export function handleVideoCoverDownloadBtn(node: HTMLElement) { 160 | if (window.location.pathname.split('/')[2] === 'reels') { 161 | const bgEl = node.querySelector('[style*="background-image"]'); 162 | if (bgEl) { 163 | const url = window 164 | .getComputedStyle(bgEl) 165 | .getPropertyValue('background-image') 166 | .match(/url\((.*)\)/)?.[1]; 167 | if (url) { 168 | downloadResource({ 169 | url: JSON.parse(url), 170 | }); 171 | } 172 | } 173 | } else { 174 | const imgSrc = node.querySelector('img')?.getAttribute('src'); 175 | if (imgSrc) { 176 | downloadResource({ 177 | url: imgSrc, 178 | }); 179 | } 180 | } 181 | } -------------------------------------------------------------------------------- /src/background/firefox.ts: -------------------------------------------------------------------------------- 1 | import {CONFIG_LIST, MESSAGE_OPEN_URL, MESSAGE_ZIP_DOWNLOAD} from '../constants'; 2 | import type {ReelsMedia} from '../types/global'; 3 | import {saveHighlights, saveProfileReel, saveReels, saveStories} from './fn'; 4 | import {BlobReader, BlobWriter, ZipWriter} from '@zip.js/zip.js'; 5 | 6 | browser.runtime.onInstalled.addListener(async () => { 7 | const result = await chrome.storage.sync.get(CONFIG_LIST); 8 | CONFIG_LIST.forEach((i) => { 9 | if (result[i] === undefined) { 10 | browser.storage.sync.set({ 11 | [i]: true, 12 | }); 13 | } 14 | }); 15 | }); 16 | 17 | browser.runtime.onStartup.addListener(() => { 18 | browser.storage.local.set({stories_user_ids: [], id_to_username_map: []}); 19 | }); 20 | 21 | async function listenInstagram(details: browser.webRequest._OnBeforeRequestDetails, jsonData: Record) { 22 | switch (details.url) { 23 | case 'https://www.instagram.com/api/graphql': 24 | saveStories(jsonData); 25 | break; 26 | case 'https://www.instagram.com/graphql/query': 27 | saveHighlights(jsonData); 28 | saveReels(jsonData); 29 | saveStories(jsonData); 30 | saveProfileReel(jsonData); 31 | break; 32 | default: 33 | if (details.url.startsWith('https://www.instagram.com/api/v1/feed/reels_media/?reel_ids=')) { 34 | const {reels, reels_media} = await browser.storage.local.get(['reels', 'reels_media']); 35 | const newArr = (reels_media || []).filter( 36 | (i: ReelsMedia.ReelsMedum) => !(jsonData as ReelsMedia.Root).reels_media.find((j) => j.id === i.id) 37 | ); 38 | await browser.storage.local.set({ 39 | reels: Object.assign({}, reels, jsonData.reels), 40 | reels_media: [...newArr, ...jsonData.reels_media], 41 | }); 42 | } 43 | break; 44 | } 45 | } 46 | 47 | function findValueByKey(obj: Record, key: string): any { 48 | for (const property in obj) { 49 | if (Object.prototype.hasOwnProperty.call(obj, property)) { 50 | if (property === key) { 51 | return obj[property]; 52 | } else if (typeof obj[property] === 'object') { 53 | const result = findValueByKey(obj[property], key); 54 | if (result !== undefined) { 55 | return result; 56 | } 57 | } 58 | } 59 | } 60 | } 61 | 62 | async function listenThreads(details: browser.webRequest._OnBeforeRequestDetails, jsonData: Record) { 63 | async function addThreads(data: any[]) { 64 | const {threads} = await browser.storage.local.get(['threads']); 65 | const newMap = new Map(threads); 66 | for (const item of data) { 67 | if (!item) continue; 68 | const code = item.post?.code || item.code; 69 | if (code) { 70 | newMap.set(code, item); 71 | } 72 | } 73 | await browser.storage.local.set({threads: Array.from(newMap)}); 74 | } 75 | 76 | if (details.url === 'https://www.threads.com/graphql/query') { 77 | if (Array.isArray(jsonData.data?.feedData?.edges)) { 78 | const data = jsonData.data.feedData.edges 79 | .map((i: any) => i.node?.text_post_app_thread?.thread_items || i.node?.thread_items || i.text_post_app_thread?.thread_items) 80 | .flat(); 81 | await addThreads(data); 82 | } else if (Array.isArray(jsonData.data?.mediaData?.edges)) { 83 | const data = jsonData.data.mediaData.edges.map((i: any) => i.node.thread_items).flat(); 84 | await addThreads(data); 85 | } else if (Array.isArray(jsonData.data?.data?.edges)) { 86 | const data = jsonData.data.data.edges.map((i: any) => i.node.thread_items).flat(); 87 | await addThreads(data); 88 | } else if (typeof jsonData.data?.replyPost === 'object') { 89 | await addThreads([jsonData.data.replyPost]); 90 | } else if (Array.isArray(jsonData.data?.searchResults?.edges)) { 91 | const data = jsonData.data.searchResults.edges.map((i: any) => i.node.thread.thread_items).flat(); 92 | await addThreads(data); 93 | } else if (Array.isArray(jsonData.data?.results?.edges)) { 94 | const data = jsonData.data.results.edges.map((i: any) => i.node.thread_items).flat(); 95 | await addThreads(data); 96 | } else if (typeof jsonData.data?.data === 'object') { 97 | const data = jsonData.data.data; 98 | await addThreads([data]); 99 | } 100 | } 101 | 102 | if (details.url === 'https://www.threads.com/ajax/route-definition/') { 103 | const result = findValueByKey(jsonData, 'searchResults'); 104 | if (result && Array.isArray(result.edges)) { 105 | await addThreads(result.edges.map((i: any) => i.node.thread.thread_items).flat()); 106 | } 107 | } 108 | } 109 | 110 | function listener(details: browser.webRequest._OnBeforeRequestDetails) { 111 | const filter = browser.webRequest.filterResponseData(details.requestId); 112 | const decoder = new TextDecoder('utf-8'); 113 | const encoder = new TextEncoder(); 114 | 115 | const data: any[] = []; 116 | filter.ondata = (event: { data: ArrayBuffer }) => { 117 | data.push(event.data); 118 | }; 119 | 120 | filter.onstop = async () => { 121 | let str = ''; 122 | if (data.length === 1) { 123 | str = decoder.decode(data[0]); 124 | } else { 125 | for (let i = 0; i < data.length; i++) { 126 | const stream = i !== data.length - 1; 127 | str += decoder.decode(data[i], {stream}); 128 | } 129 | } 130 | 131 | // !use try catch to avoid error that may cause page not working 132 | try { 133 | const jsonData = JSON.parse(str); 134 | listenInstagram(details, jsonData); 135 | listenThreads(details, jsonData); 136 | } catch { 137 | try { 138 | // record opened stories by user_id and username 139 | // routePath "/stories/{username}/{?initial_media_id}/" 140 | if (details.url === 'https://www.instagram.com/ajax/bulk-route-definitions/') { 141 | const { 142 | payload: {payloads}, 143 | } = JSON.parse(str.split(/\s*for\s+\(;;\);\s*/)[1]); 144 | const { 145 | stories_user_ids, 146 | id_to_username_map 147 | } = await browser.storage.local.get(['stories_user_ids', 'id_to_username_map']); 148 | const nameToId = new Map(stories_user_ids); 149 | const idToName = new Map(id_to_username_map); 150 | for (const [key, value] of Object.entries(payloads)) { 151 | if (key.startsWith('/stories/')) { 152 | // @ts-ignore 153 | const {rootView} = value.result.exports; 154 | nameToId.set(key.split('/')[2], rootView.props.user_id); 155 | idToName.set(rootView.props.user_id, key.split('/')[2]); 156 | } 157 | } 158 | browser.storage.local.set({ 159 | stories_user_ids: Array.from(nameToId), 160 | id_to_username_map: Array.from(idToName) 161 | }); 162 | } 163 | if (details.url === 'https://www.threads.com/ajax/route-definition/' && str.includes('searchResults')) { 164 | str.split(/\s*for\s+\(;;\);\s*/) 165 | .filter((_) => _) 166 | .map((i) => listenThreads(details, JSON.parse(i))); 167 | } 168 | } catch { 169 | } 170 | } 171 | 172 | filter.write(encoder.encode(str)); 173 | filter.close(); 174 | }; 175 | } 176 | 177 | browser.webRequest.onBeforeRequest.addListener( 178 | (details) => { 179 | try { 180 | const {method, url} = details; 181 | const {pathname} = new URL(url); 182 | 183 | if (method === 'GET' && pathname.startsWith('/api/v1/feed/user/') && pathname.endsWith('/username/')) { 184 | listener(details); // get user hd_profile_pic_url_info 185 | } 186 | if (method === 'GET' && url.startsWith('https://www.instagram.com/api/v1/feed/reels_media/?reel_ids=')) { 187 | listener(details); // presentation stories in home page top 188 | } 189 | if (method === 'POST' && url === 'https://www.instagram.com/api/graphql') { 190 | listener(details); 191 | } 192 | if (method === 'POST' && url === 'https://www.instagram.com/graphql/query') { 193 | listener(details); // save highlights data and reels data 194 | } 195 | if (method === 'POST' && url === 'https://www.instagram.com/ajax/bulk-route-definitions/') { 196 | listener(details); 197 | } 198 | 199 | // threads 200 | if (method === 'POST' && url === 'https://www.threads.com/graphql/query') { 201 | listener(details); 202 | } 203 | if (method === 'POST' && url === 'https://www.threads.com/ajax/route-definition/') { 204 | listener(details); 205 | } 206 | } catch { 207 | } 208 | }, 209 | {urls: ['https://www.instagram.com/*', 'https://www.threads.com/*']}, 210 | ['blocking'] 211 | ); 212 | 213 | browser.runtime.onInstalled.addListener(async () => { 214 | if ( 215 | !(await browser.permissions.contains({ 216 | origins: ['https://www.instagram.com/*', 'https://www.threads.com/*'], 217 | })) 218 | ) { 219 | await browser.runtime.openOptionsPage(); 220 | } 221 | }); 222 | 223 | browser.runtime.onMessage.addListener(async (message, sender) => { 224 | console.log(message, sender); 225 | const {type, data} = message; 226 | switch (type) { 227 | case MESSAGE_OPEN_URL: 228 | await browser.tabs.create({url: data, index: sender.tab!.index + 1}); 229 | break; 230 | case MESSAGE_ZIP_DOWNLOAD: 231 | const zipFileWriter = new BlobWriter(); 232 | const zipWriter = new ZipWriter(zipFileWriter); 233 | for (const item of data.blobList) { 234 | const {filename, content} = item; 235 | let extension = content.type.split('/').pop() || 'jpg'; 236 | const {setting_format_replace_jpeg_with_jpg} = await browser.storage.sync.get(['setting_format_replace_jpeg_with_jpg']); 237 | if (setting_format_replace_jpeg_with_jpg) { 238 | extension = extension.replace('jpeg', 'jpg'); 239 | } 240 | await zipWriter.add(filename + '.' + extension, new BlobReader(content), { 241 | useWebWorkers: false, 242 | }); 243 | } 244 | const zipContent = await zipWriter.close(); 245 | const blobUrl = URL.createObjectURL(zipContent); 246 | downloadZip(blobUrl, data.zipFileName + '.zip'); 247 | break 248 | } 249 | }); 250 | 251 | function downloadZip(url: string, filename: string) { 252 | const a = document.createElement('a'); 253 | a.href = url; 254 | a.download = filename; 255 | document.body.appendChild(a); 256 | a.click(); 257 | a.remove(); 258 | URL.revokeObjectURL(url); 259 | } 260 | -------------------------------------------------------------------------------- /src/content/utils/fn.ts: -------------------------------------------------------------------------------- 1 | import type {Dayjs} from 'dayjs'; 2 | import dayjs from 'dayjs'; 3 | import {DEFAULT_DATETIME_FORMAT, DEFAULT_FILENAME_FORMAT, MESSAGE_OPEN_URL} from '../../constants'; 4 | 5 | export async function openInNewTab(url: string) { 6 | try { 7 | await chrome.runtime.sendMessage({type: MESSAGE_OPEN_URL, data: url}); 8 | } catch { 9 | window.open(url, '_blank', 'noopener,noreferrer'); 10 | } 11 | } 12 | 13 | async function forceDownload(blob: string, filename: string, extension: string) { 14 | const {setting_format_replace_jpeg_with_jpg} = await chrome.storage.sync.get(['setting_format_replace_jpeg_with_jpg']); 15 | if (setting_format_replace_jpeg_with_jpg) { 16 | extension = extension.replace('jpeg', 'jpg'); 17 | } 18 | const a = document.createElement('a'); 19 | a.download = filename + '.' + extension; 20 | a.href = blob; 21 | document.body.appendChild(a); 22 | a.click(); 23 | a.remove(); 24 | URL.revokeObjectURL(blob); 25 | } 26 | 27 | export function getMediaName(url: string) { 28 | const name = url.split('?')[0].split('/').pop(); 29 | return name ? name.substring(0, name.lastIndexOf('.')) : url; 30 | } 31 | 32 | export interface DownloadParams { 33 | url: string; 34 | username?: string; 35 | datetime?: string | null | Dayjs | number; 36 | fileId?: string; 37 | } 38 | 39 | function hashCode(str: string) { 40 | let hash = 0; 41 | for (let i = 0; i < str.length; i++) { 42 | const char = str.charCodeAt(i); 43 | hash = (hash << 5) - hash + char; 44 | hash = hash & hash; 45 | } 46 | return hash >>> 0; 47 | } 48 | 49 | export const getFilenameFromUrl = async ({url, username, datetime, fileId}: DownloadParams) => { 50 | const { 51 | setting_format_datetime = DEFAULT_DATETIME_FORMAT, 52 | setting_format_filename = DEFAULT_FILENAME_FORMAT, 53 | setting_format_use_hash_id, 54 | setting_enable_datetime_format, 55 | } = await chrome.storage.sync.get([ 56 | 'setting_format_datetime', 57 | 'setting_format_filename', 58 | 'setting_format_use_hash_id', 59 | 'setting_enable_datetime_format', 60 | ]); 61 | 62 | // When setting_format_use_hash_id is true, we will hash the fileId. The mediaIndex will be meaningless. 63 | if (setting_format_use_hash_id && fileId) { 64 | fileId = hashCode(fileId).toString(); 65 | } 66 | 67 | let filename = fileId; 68 | 69 | if (username && datetime && fileId) { 70 | console.log(`username: ${username}, datetime: ${datetime}, fileId: ${fileId}`); 71 | 72 | datetime = setting_enable_datetime_format ? dayjs(datetime).format(setting_format_datetime) : dayjs(datetime).unix(); 73 | 74 | filename = setting_format_filename 75 | .replace(/{username}/g, username) 76 | .replace(/{datetime}/g, datetime) 77 | .replace(/{id}/g, fileId); 78 | } 79 | 80 | if (!filename) { 81 | filename = getMediaName(url); 82 | } 83 | return filename; 84 | }; 85 | 86 | export async function downloadResource({url, username, datetime, fileId}: DownloadParams) { 87 | console.log(`Downloading ${url}`); 88 | const filename = await getFilenameFromUrl({url, username, datetime, fileId}); 89 | 90 | if (url.startsWith('blob:')) { 91 | forceDownload(url, filename, 'mp4'); 92 | return; 93 | } 94 | fetch(url, { 95 | headers: new Headers({ 96 | Origin: location.origin, 97 | }), 98 | mode: 'cors', 99 | }) 100 | .then((response) => response.blob()) 101 | .then((blob) => { 102 | const extension = blob.type.split('/').pop(); 103 | const blobUrl = window.URL.createObjectURL(blob); 104 | forceDownload(blobUrl, filename, extension || 'jpg'); 105 | }) 106 | .catch((e) => console.error(e)); 107 | } 108 | 109 | const mediaInfoCache: Map = new Map(); // key: media id, value: info json 110 | const mediaIdCache: Map = new Map(); // key: post id, value: media id 111 | 112 | const findAppId = () => { 113 | const appIdPattern = /"X-IG-App-ID":"([\d]+)"/; 114 | const bodyScripts: NodeListOf = document.querySelectorAll('body > script'); 115 | for (let i = 0; i < bodyScripts.length; ++i) { 116 | const match = bodyScripts[i].text.match(appIdPattern); 117 | if (match) return match[1]; 118 | } 119 | console.log('Cannot find app id'); 120 | return null; 121 | }; 122 | 123 | function findPostId(articleNode: HTMLElement) { 124 | const pathname = window.location.pathname; 125 | if (pathname.startsWith('/reels/')) { 126 | return pathname.split('/')[2]; 127 | } else if (pathname.startsWith('/stories/')) { 128 | return pathname.split('/')[3]; 129 | } else if (pathname.startsWith('/reel/')) { 130 | return pathname.split('/')[2]; 131 | } 132 | const postIdPattern = /^\/p\/([^/]+)\//; 133 | const aNodes = articleNode.querySelectorAll('a'); 134 | for (let i = 0; i < aNodes.length; ++i) { 135 | const link = aNodes[i].getAttribute('href'); 136 | if (link) { 137 | const match = link.match(postIdPattern); 138 | if (match) return match[1]; 139 | } 140 | } 141 | return null; 142 | } 143 | 144 | const findMediaId = async (postId: string) => { 145 | const mediaIdPattern = /instagram:\/\/media\?id=(\d+)|["' ]media_id["' ]:["' ](\d+)["' ]/; 146 | const match = window.location.href.match(/www.instagram.com\/stories\/[^/]+\/(\d+)/); 147 | if (match) return match[1]; 148 | if (!mediaIdCache.has(postId)) { 149 | const postUrl = `https://www.instagram.com/p/${postId}/`; 150 | const resp = await fetch(postUrl); 151 | const text = await resp.text(); 152 | const idMatch = text.match(mediaIdPattern); 153 | if (!idMatch) return null; 154 | let mediaId = null; 155 | for (let i = 0; i < idMatch.length; ++i) { 156 | if (idMatch[i]) mediaId = idMatch[i]; 157 | } 158 | if (!mediaId) return null; 159 | mediaIdCache.set(postId, mediaId); 160 | } 161 | return mediaIdCache.get(postId); 162 | }; 163 | 164 | export const getImgOrVideoUrl = (item: Record) => { 165 | if ('video_versions' in item) { 166 | return item.video_versions[0].url; 167 | } else { 168 | return item.image_versions2.candidates[0].url; 169 | } 170 | }; 171 | 172 | export const getDataFromAPI = async (articleNode: HTMLElement) => { 173 | try { 174 | const appId = findAppId(); 175 | if (!appId) { 176 | console.log('Cannot find appid'); 177 | return null; 178 | } 179 | const postId = findPostId(articleNode); 180 | if (!postId) { 181 | console.log('Cannot find post id'); 182 | return null; 183 | } 184 | const mediaId = await findMediaId(postId); 185 | if (!mediaId) { 186 | console.log('Cannot find media id'); 187 | return null; 188 | } 189 | if (!mediaInfoCache.has(mediaId)) { 190 | const url = 'https://i.instagram.com/api/v1/media/' + mediaId + '/info/'; 191 | const resp = await fetch(url, { 192 | method: 'GET', 193 | headers: { 194 | Accept: '*/*', 195 | 'X-IG-App-ID': appId, 196 | }, 197 | credentials: 'include', 198 | mode: 'cors', 199 | }); 200 | 201 | if (resp.status !== 200) { 202 | console.log(`Fetch info API failed with status code: ${resp.status}`); 203 | return null; 204 | } 205 | const respJson = await resp.json(); 206 | mediaInfoCache.set(mediaId, respJson); 207 | } 208 | const infoJson = mediaInfoCache.get(mediaId); 209 | return infoJson.items[0]; 210 | } catch (e: any) { 211 | console.log(`Uncaught in getUrlFromInfoApi(): ${e}\n${e.stack}`); 212 | return null; 213 | } 214 | }; 215 | 216 | export const getUrlFromInfoApi = async (articleNode: HTMLElement, mediaIdx = 0): Promise | null> => { 217 | const data = await getDataFromAPI(articleNode); 218 | if (!data) return null; 219 | 220 | if ('carousel_media' in data) { 221 | // multi-media post 222 | const item = data.carousel_media[Math.max(mediaIdx, 0)]; 223 | return { 224 | ...item, 225 | url: getImgOrVideoUrl(item), 226 | taken_at: data.taken_at, 227 | owner: item.owner?.username || data.owner?.username || "unknown", 228 | coauthor_producers: data.coauthor_producers?.map((i: any) => i.username) || [], 229 | origin_data: data, 230 | }; 231 | } else { 232 | // single media post 233 | return { 234 | ...data, 235 | url: getImgOrVideoUrl(data), 236 | owner: data.owner?.username || "unknown", 237 | coauthor_producers: data.coauthor_producers?.map((i: any) => i.username) || [], 238 | }; 239 | } 240 | }; 241 | 242 | function adjustVideoButton(btns: NodeListOf) { 243 | btns.forEach((i) => { 244 | const btn = i.parentNode?.parentNode?.parentNode?.parentNode; 245 | if (btn instanceof HTMLElement) { 246 | btn.style.zIndex = '999'; 247 | btn.style.bottom = '3rem'; 248 | } 249 | }); 250 | } 251 | 252 | export async function handleVideo() { 253 | const {setting_enable_video_controls} = await chrome.storage.sync.get(['setting_enable_video_controls']); 254 | if (!setting_enable_video_controls) return; 255 | const videos = document.querySelectorAll('video'); 256 | for (let i = 0; i < videos.length; i++) { 257 | if (videos[i].controls === true) continue; 258 | videos[i].style.zIndex = '1'; 259 | videos[i].style.position = 'relative'; 260 | videos[i].controls = true; 261 | videos[i].onvolumechange = () => { 262 | const isMutingBtn = videos[i].parentElement?.querySelector( 263 | 'path[d="M1.5 13.3c-.8 0-1.5.7-1.5 1.5v18.4c0 .8.7 1.5 1.5 1.5h8.7l12.9 12.9c.9.9 2.5.3 2.5-1v-9.8c0-.4-.2-.8-.4-1.1l-22-22c-.3-.3-.7-.4-1.1-.4h-.6zm46.8 31.4-5.5-5.5C44.9 36.6 48 31.4 48 24c0-11.4-7.2-17.4-7.2-17.4-.6-.6-1.6-.6-2.2 0L37.2 8c-.6.6-.6 1.6 0 2.2 0 0 5.7 5 5.7 13.8 0 5.4-2.1 9.3-3.8 11.6L35.5 32c1.1-1.7 2.3-4.4 2.3-8 0-6.8-4.1-10.3-4.1-10.3-.6-.6-1.6-.6-2.2 0l-1.4 1.4c-.6.6-.6 1.6 0 2.2 0 0 2.6 2 2.6 6.7 0 1.8-.4 3.2-.9 4.3L25.5 22V1.4c0-1.3-1.6-1.9-2.5-1L13.5 10 3.3-.3c-.6-.6-1.5-.6-2.1 0L-.2 1.1c-.6.6-.6 1.5 0 2.1L4 7.6l26.8 26.8 13.9 13.9c.6.6 1.5.6 2.1 0l1.4-1.4c.7-.6.7-1.6.1-2.2z"]' 264 | ); 265 | const isUnmutingBtn = videos[i].parentElement?.querySelector( 266 | 'path[d="M16.636 7.028a1.5 1.5 0 1 0-2.395 1.807 5.365 5.365 0 0 1 1.103 3.17 5.378 5.378 0 0 1-1.105 3.176 1.5 1.5 0 1 0 2.395 1.806 8.396 8.396 0 0 0 1.71-4.981 8.39 8.39 0 0 0-1.708-4.978Zm3.73-2.332A1.5 1.5 0 1 0 18.04 6.59 8.823 8.823 0 0 1 20 12.007a8.798 8.798 0 0 1-1.96 5.415 1.5 1.5 0 0 0 2.326 1.894 11.672 11.672 0 0 0 2.635-7.31 11.682 11.682 0 0 0-2.635-7.31Zm-8.963-3.613a1.001 1.001 0 0 0-1.082.187L5.265 6H2a1 1 0 0 0-1 1v10.003a1 1 0 0 0 1 1h3.265l5.01 4.682.02.021a1 1 0 0 0 1.704-.814L12.005 2a1 1 0 0 0-.602-.917Z"]' 267 | ); 268 | if (videos[i].muted === false && isMutingBtn) { 269 | isMutingBtn.parentElement?.parentElement?.parentElement?.click(); 270 | } 271 | if (videos[i].muted === true && isUnmutingBtn) { 272 | isUnmutingBtn.parentElement?.parentElement?.parentElement?.click(); 273 | } 274 | }; 275 | const btns = videos[i].parentNode?.querySelectorAll('button svg path'); 276 | if (btns) { 277 | adjustVideoButton(btns); 278 | } 279 | } 280 | } 281 | 282 | export const checkType = () => { 283 | if (navigator && navigator.userAgent && /Mobi|Android|iPhone/i.test(navigator.userAgent)) { 284 | if (navigator && navigator.userAgent && /(iPhone|iPad|iPod|iOS)/i.test(navigator.userAgent)) { 285 | return 'ios'; 286 | } else { 287 | return 'android'; 288 | } 289 | } else { 290 | return 'pc'; 291 | } 292 | }; 293 | 294 | export async function fetchHtml() { 295 | const resp = await fetch(window.location.href, { 296 | referrerPolicy: 'no-referrer', 297 | }); 298 | const content = await resp.text(); 299 | const parser = new DOMParser(); 300 | const doc = parser.parseFromString(content, 'text/html'); 301 | return doc.querySelectorAll('script'); 302 | } 303 | 304 | -------------------------------------------------------------------------------- /src/content/index.ts: -------------------------------------------------------------------------------- 1 | import {CLASS_CUSTOM_BUTTON} from '../constants'; 2 | import {addCustomBtn, addVideoDownloadCoverBtn, handleVideoCoverDownloadBtn, onClickHandler} from './button'; 3 | import {handleThreads} from './threads'; 4 | import {checkType, handleVideo} from './utils/fn'; 5 | 6 | setInterval(() => { 7 | requestIdleCallback(() => { 8 | if (window.location.origin === 'https://www.threads.com') { 9 | chrome.storage.sync.get(['setting_enable_threads']).then((result) => { 10 | if (result.setting_enable_threads) { 11 | handleThreads(); 12 | } 13 | }); 14 | return; 15 | } 16 | if (window.location.origin !== 'https://www.instagram.com') return; 17 | 18 | const cs = document.documentElement.style.colorScheme || getComputedStyle(document.documentElement).colorScheme; 19 | 20 | const isDark = cs === 'dark' || window.matchMedia('(prefers-color-scheme: dark)').matches; 21 | 22 | const iconColor = isDark ? 'white' : 'black'; 23 | 24 | const pathname = window.location.pathname; 25 | const pathnameList = pathname.split('/').filter((e) => e); 26 | 27 | const isFeedPage = pathnameList.length === 2 && pathnameList[1] === 'feed'; 28 | const isPostDetailWithNameInUrl = pathnameList.length === 3 && pathnameList[1] === 'p'; // https://www.instagram.com/frankinjection/p/CwAb4TEoRE_/?img_index=1 29 | const isReelDetailWithNameInUrl = pathnameList.length === 3 && pathnameList[1] === 'reel'; // https://www.instagram.com/philsnelgrove/reel/B5GeRJoBAc1/ 30 | 31 | // home page and feed page 32 | if (pathname === '/' || isFeedPage) { 33 | handleVideo(); 34 | 35 | const articleList = document.querySelectorAll('article'); 36 | for (let i = 0; i < articleList.length; i++) { 37 | const tagNode = articleList[i].querySelector( 38 | 'path[d="M21.334 23H2.666a1 1 0 0 1-1-1v-1.354a6.279 6.279 0 0 1 6.272-6.272h8.124a6.279 6.279 0 0 1 6.271 6.271V22a1 1 0 0 1-1 1ZM12 13.269a6 6 0 1 1 6-6 6.007 6.007 0 0 1-6 6Z"]' 39 | ); 40 | if (tagNode) { 41 | articleList[i].querySelectorAll('ul li img').forEach((img) => { 42 | const emptyNode = img.parentElement?.nextElementSibling; 43 | if (emptyNode instanceof HTMLDivElement && emptyNode.childNodes.length === 0) { 44 | emptyNode.style.zIndex = '-1'; 45 | } 46 | }); 47 | } else { 48 | articleList[i].querySelectorAll(':scope img').forEach((img) => { 49 | img.style.zIndex = '999'; 50 | }); 51 | } 52 | // use like btn to position, because like btn is always exist 53 | const likeBtn = articleList[i].querySelector( 54 | 'path[d="M16.792 3.904A4.989 4.989 0 0 1 21.5 9.122c0 3.072-2.652 4.959-5.197 7.222-2.512 2.243-3.865 3.469-4.303 3.752-.477-.309-2.143-1.823-4.303-3.752C5.141 14.072 2.5 12.167 2.5 9.122a4.989 4.989 0 0 1 4.708-5.218 4.21 4.21 0 0 1 3.675 1.941c.84 1.175.98 1.763 1.12 1.763s.278-.588 1.11-1.766a4.17 4.17 0 0 1 3.679-1.938m0-2a6.04 6.04 0 0 0-4.797 2.127 6.052 6.052 0 0 0-4.787-2.127A6.985 6.985 0 0 0 .5 9.122c0 3.61 2.55 5.827 5.015 7.97.283.246.569.494.853.747l1.027.918a44.998 44.998 0 0 0 3.518 3.018 2 2 0 0 0 2.174 0 45.263 45.263 0 0 0 3.626-3.115l.922-.824c.293-.26.59-.519.885-.774 2.334-2.025 4.98-4.32 4.98-7.94a6.985 6.985 0 0 0-6.708-7.218Z"]' 55 | ); 56 | if (likeBtn && articleList[i].getElementsByClassName(CLASS_CUSTOM_BUTTON).length === 0) { 57 | addCustomBtn(likeBtn.parentNode?.parentNode?.parentNode?.parentNode?.parentNode?.parentNode?.parentNode, iconColor); 58 | } 59 | } 60 | } 61 | 62 | // post 63 | if (pathname.startsWith('/p/') || isPostDetailWithNameInUrl || isReelDetailWithNameInUrl) { 64 | handleVideo(); 65 | const dialogNode = document.querySelector('div[role="dialog"]'); 66 | const tagNode = document.querySelector( 67 | 'path[d="M21.334 23H2.666a1 1 0 0 1-1-1v-1.354a6.279 6.279 0 0 1 6.272-6.272h8.124a6.279 6.279 0 0 1 6.271 6.271V22a1 1 0 0 1-1 1ZM12 13.269a6 6 0 1 1 6-6 6.007 6.007 0 0 1-6 6Z"]' 68 | ); 69 | if (tagNode) { 70 | const node = dialogNode ?? document.querySelector('section main'); 71 | if (node) { 72 | node.querySelectorAll('ul li img').forEach((img) => { 73 | const emptyNode = img.parentElement?.nextElementSibling; 74 | if (emptyNode instanceof HTMLDivElement && emptyNode.childNodes.length === 0) { 75 | emptyNode.style.zIndex = '-1'; 76 | } 77 | }); 78 | } 79 | } else if (dialogNode) { 80 | dialogNode.querySelectorAll('img').forEach((img) => { 81 | img.style.zIndex = '999'; 82 | }); 83 | } else { 84 | document 85 | .querySelector('main > div > div') 86 | ?.querySelectorAll('img') 87 | .forEach((img) => (img.style.zIndex = '999')); 88 | } 89 | const replyBtn = document.querySelector('path[d="M20.656 17.008a9.993 9.993 0 1 0-3.59 3.615L22 22Z"]'); 90 | const btnsContainer = 91 | document.querySelector('div[role="presentation"] section') || 92 | replyBtn?.parentNode?.parentNode?.parentNode?.parentNode?.parentNode; 93 | 94 | if (btnsContainer instanceof HTMLElement && btnsContainer.getElementsByClassName(CLASS_CUSTOM_BUTTON).length === 0) { 95 | addCustomBtn(btnsContainer, iconColor); 96 | } 97 | } 98 | 99 | // stories 100 | if (pathname.startsWith('/stories/')) { 101 | const node = document.querySelector('section')?.querySelector('img[decoding="sync"]')?.nextSibling; 102 | if (node instanceof HTMLDivElement) { 103 | node.style.zIndex = '-1'; 104 | } 105 | const blockDiv = [...document.querySelectorAll('body>div:not(#splash-screen)>div>div>div>div')].find((el) => { 106 | const rect = el.getBoundingClientRect(); 107 | return rect.width > 0 && rect.height > 0; 108 | }); 109 | const storyMenuBtn = blockDiv?.querySelector('svg circle'); 110 | if (storyMenuBtn && blockDiv?.getElementsByClassName(CLASS_CUSTOM_BUTTON).length === 0) { 111 | addCustomBtn(storyMenuBtn.parentNode?.parentNode?.parentNode?.parentNode?.parentNode, 'white'); 112 | } 113 | chrome.storage.sync.get(['setting_enable_video_controls']).then((result) => { 114 | if (!result.setting_enable_video_controls) return; 115 | const videos = document.querySelectorAll('video'); 116 | for (let i = 0; i < videos.length; i++) { 117 | if (videos[i].controls === true) continue; 118 | videos[i].style.zIndex = '1'; 119 | videos[i].style.position = 'relative'; 120 | videos[i].setAttribute('controls', 'true'); 121 | videos[i].onvolumechange = () => { 122 | const isMutingBtn = document.querySelector( 123 | 'section path[d="M1.5 13.3c-.8 0-1.5.7-1.5 1.5v18.4c0 .8.7 1.5 1.5 1.5h8.7l12.9 12.9c.9.9 2.5.3 2.5-1v-9.8c0-.4-.2-.8-.4-1.1l-22-22c-.3-.3-.7-.4-1.1-.4h-.6zm46.8 31.4-5.5-5.5C44.9 36.6 48 31.4 48 24c0-11.4-7.2-17.4-7.2-17.4-.6-.6-1.6-.6-2.2 0L37.2 8c-.6.6-.6 1.6 0 2.2 0 0 5.7 5 5.7 13.8 0 5.4-2.1 9.3-3.8 11.6L35.5 32c1.1-1.7 2.3-4.4 2.3-8 0-6.8-4.1-10.3-4.1-10.3-.6-.6-1.6-.6-2.2 0l-1.4 1.4c-.6.6-.6 1.6 0 2.2 0 0 2.6 2 2.6 6.7 0 1.8-.4 3.2-.9 4.3L25.5 22V1.4c0-1.3-1.6-1.9-2.5-1L13.5 10 3.3-.3c-.6-.6-1.5-.6-2.1 0L-.2 1.1c-.6.6-.6 1.5 0 2.1L4 7.6l26.8 26.8 13.9 13.9c.6.6 1.5.6 2.1 0l1.4-1.4c.7-.6.7-1.6.1-2.2z"]' 124 | ); 125 | const isUnmutingBtn = document.querySelector( 126 | 'section path[d="M16.636 7.028a1.5 1.5 0 1 0-2.395 1.807 5.365 5.365 0 0 1 1.103 3.17 5.378 5.378 0 0 1-1.105 3.176 1.5 1.5 0 1 0 2.395 1.806 8.396 8.396 0 0 0 1.71-4.981 8.39 8.39 0 0 0-1.708-4.978Zm3.73-2.332A1.5 1.5 0 1 0 18.04 6.59 8.823 8.823 0 0 1 20 12.007a8.798 8.798 0 0 1-1.96 5.415 1.5 1.5 0 0 0 2.326 1.894 11.672 11.672 0 0 0 2.635-7.31 11.682 11.682 0 0 0-2.635-7.31Zm-8.963-3.613a1.001 1.001 0 0 0-1.082.187L5.265 6H2a1 1 0 0 0-1 1v10.003a1 1 0 0 0 1 1h3.265l5.01 4.682.02.021a1 1 0 0 0 1.704-.814L12.005 2a1 1 0 0 0-.602-.917Z"]' 127 | ); 128 | if (videos[i].muted === false && isMutingBtn) { 129 | isMutingBtn.parentElement?.parentElement?.parentElement?.click(); 130 | } 131 | if (videos[i].muted === true && isUnmutingBtn) { 132 | isUnmutingBtn.parentElement?.parentElement?.parentElement?.click(); 133 | } 134 | }; 135 | } 136 | }); 137 | } 138 | 139 | // reels 140 | if (pathname.startsWith('/reels/')) { 141 | chrome.storage.sync.get(['setting_enable_video_controls']).then((result) => { 142 | if (!result.setting_enable_video_controls) return; 143 | // handle video 144 | const videos = document.querySelectorAll('video'); 145 | for (let i = 0; i < videos.length; i++) { 146 | if (videos[i].controls === true) continue; 147 | videos[i].style.zIndex = '1'; 148 | videos[i].style.position = 'relative'; 149 | videos[i].controls = true; 150 | videos[i].onvolumechange = () => { 151 | const isMutingBtn = videos[i].parentElement?.querySelector( 152 | 'path[d="M1.5 13.3c-.8 0-1.5.7-1.5 1.5v18.4c0 .8.7 1.5 1.5 1.5h8.7l12.9 12.9c.9.9 2.5.3 2.5-1v-9.8c0-.4-.2-.8-.4-1.1l-22-22c-.3-.3-.7-.4-1.1-.4h-.6zm46.8 31.4-5.5-5.5C44.9 36.6 48 31.4 48 24c0-11.4-7.2-17.4-7.2-17.4-.6-.6-1.6-.6-2.2 0L37.2 8c-.6.6-.6 1.6 0 2.2 0 0 5.7 5 5.7 13.8 0 5.4-2.1 9.3-3.8 11.6L35.5 32c1.1-1.7 2.3-4.4 2.3-8 0-6.8-4.1-10.3-4.1-10.3-.6-.6-1.6-.6-2.2 0l-1.4 1.4c-.6.6-.6 1.6 0 2.2 0 0 2.6 2 2.6 6.7 0 1.8-.4 3.2-.9 4.3L25.5 22V1.4c0-1.3-1.6-1.9-2.5-1L13.5 10 3.3-.3c-.6-.6-1.5-.6-2.1 0L-.2 1.1c-.6.6-.6 1.5 0 2.1L4 7.6l26.8 26.8 13.9 13.9c.6.6 1.5.6 2.1 0l1.4-1.4c.7-.6.7-1.6.1-2.2z"]' 153 | ); 154 | const isUnmutingBtn = videos[i].nextElementSibling?.querySelector( 155 | 'path[d="M16.636 7.028a1.5 1.5 0 10-2.395 1.807 5.365 5.365 0 011.103 3.17 5.378 5.378 0 01-1.105 3.176 1.5 1.5 0 102.395 1.806 8.396 8.396 0 001.71-4.981 8.39 8.39 0 00-1.708-4.978zm3.73-2.332A1.5 1.5 0 1018.04 6.59 8.823 8.823 0 0120 12.007a8.798 8.798 0 01-1.96 5.415 1.5 1.5 0 002.326 1.894 11.672 11.672 0 002.635-7.31 11.682 11.682 0 00-2.635-7.31zm-8.963-3.613a1.001 1.001 0 00-1.082.187L5.265 6H2a1 1 0 00-1 1v10.003a1 1 0 001 1h3.265l5.01 4.682.02.021a1 1 0 001.704-.814L12.005 2a1 1 0 00-.602-.917z"]' 156 | ); 157 | if (videos[i].muted === false && isMutingBtn) { 158 | isMutingBtn.parentElement?.parentElement?.click(); 159 | } 160 | if (videos[i].muted === true && isUnmutingBtn) { 161 | isUnmutingBtn.parentElement?.parentElement?.click(); 162 | } 163 | }; 164 | const btnEl = videos[i].nextElementSibling?.querySelector('div[role=button]'); 165 | if (btnEl) { 166 | btnEl.style.paddingBottom = '3rem'; 167 | btnEl.childNodes.forEach((i) => i instanceof HTMLDivElement && (i.style.zIndex = '999')); 168 | } 169 | } 170 | }); 171 | 172 | const reelsList = 173 | checkType() === 'pc' 174 | ? document.querySelectorAll('section>main>div>div') 175 | : document.querySelectorAll('section>main>div>div>div'); 176 | for (const item of reelsList) { 177 | const likeBtn = item.querySelector( 178 | 'path[d="M16.792 3.904A4.989 4.989 0 0 1 21.5 9.122c0 3.072-2.652 4.959-5.197 7.222-2.512 2.243-3.865 3.469-4.303 3.752-.477-.309-2.143-1.823-4.303-3.752C5.141 14.072 2.5 12.167 2.5 9.122a4.989 4.989 0 0 1 4.708-5.218 4.21 4.21 0 0 1 3.675 1.941c.84 1.175.98 1.763 1.12 1.763s.278-.588 1.11-1.766a4.17 4.17 0 0 1 3.679-1.938m0-2a6.04 6.04 0 0 0-4.797 2.127 6.052 6.052 0 0 0-4.787-2.127A6.985 6.985 0 0 0 .5 9.122c0 3.61 2.55 5.827 5.015 7.97.283.246.569.494.853.747l1.027.918a44.998 44.998 0 0 0 3.518 3.018 2 2 0 0 0 2.174 0 45.263 45.263 0 0 0 3.626-3.115l.922-.824c.293-.26.59-.519.885-.774 2.334-2.025 4.98-4.32 4.98-7.94a6.985 6.985 0 0 0-6.708-7.218Z"]' 179 | ); 180 | if (likeBtn && item.getElementsByClassName(CLASS_CUSTOM_BUTTON).length === 0) { 181 | addCustomBtn( 182 | likeBtn.parentNode?.parentNode?.parentNode?.parentNode?.parentNode?.parentNode?.parentNode?.parentNode, 183 | checkType() === 'pc' ? iconColor : 'white', 184 | 'before' 185 | ); 186 | } 187 | } 188 | } 189 | 190 | // reel 191 | if (pathname.startsWith('/reel/')) { 192 | handleVideo(); 193 | 194 | const dialogNode = document.querySelector('div[role="dialog"]'); 195 | // use dialogNode because there is already a btn for downloading avatar inside the page 196 | const node = dialogNode || document; 197 | const commentBtn = node.querySelector('path[d="M20.656 17.008a9.993 9.993 0 1 0-3.59 3.615L22 22Z"]'); 198 | if (commentBtn && node.getElementsByClassName(CLASS_CUSTOM_BUTTON).length === 0) { 199 | addCustomBtn(commentBtn.parentNode?.parentNode?.parentNode?.parentNode?.parentNode, iconColor, 'before'); 200 | } 201 | } 202 | 203 | // user Avatar 204 | const profileHeader = document.querySelector('section>main>div>header>section:nth-child(2)'); 205 | if (profileHeader && profileHeader.getElementsByClassName(CLASS_CUSTOM_BUTTON).length === 0) { 206 | const profileBtn = profileHeader.querySelector('svg circle'); 207 | if (profileBtn) { 208 | addCustomBtn(profileBtn.parentNode?.parentNode?.parentNode, iconColor); 209 | } 210 | } 211 | 212 | // user's profile page video cover 213 | if (pathnameList.length === 1 || (pathnameList.length === 2 && ['tagged', 'reels'].includes(pathnameList[1]))) { 214 | const postsRows = document.querySelector('header')?.parentElement 215 | ?.lastElementChild 216 | ?.querySelectorAll(`:scope>div>div>div>div ${pathnameList.length === 1 ? '>div' : ''}`); 217 | 218 | const VIDEO_SVG_PATH = "M22.942 7.464c-.062-1.36-.306-2.143-.511-2.671a5.366 5.366 0 0 0-1.272-1.952 5.364 5.364 0 0 0-1.951-1.27c-.53-.207-1.312-.45-2.673-.513-1.2-.054-1.557-.066-4.535-.066s-3.336.012-4.536.066c-1.36.062-2.143.306-2.672.511-.769.3-1.371.692-1.951 1.272s-.973 1.182-1.27 1.951c-.207.53-.45 1.312-.513 2.673C1.004 8.665.992 9.022.992 12s.012 3.336.066 4.536c.062 1.36.306 2.143.511 2.671.298.77.69 1.373 1.272 1.952.58.581 1.182.974 1.951 1.27.53.207 1.311.45 2.673.513 1.199.054 1.557.066 4.535.066s3.336-.012 4.536-.066c1.36-.062 2.143-.306 2.671-.511a5.368 5.368 0 0 0 1.953-1.273c.58-.58.972-1.181 1.27-1.95.206-.53.45-1.312.512-2.673.054-1.2.066-1.557.066-4.535s-.012-3.336-.066-4.536Zm-7.085 6.055-5.25 3c-1.167.667-2.619-.175-2.619-1.519V9c0-1.344 1.452-2.186 2.619-1.52l5.25 3c1.175.672 1.175 2.368 0 3.04Z" 219 | postsRows?.forEach((row) => { 220 | row.childNodes.forEach((item) => { 221 | if (item instanceof HTMLDivElement && item.getElementsByClassName(CLASS_CUSTOM_BUTTON).length === 0) { 222 | const videoSvg = item.querySelector(`path[d="${VIDEO_SVG_PATH}"]`); 223 | if (videoSvg || pathnameList.includes('reels')) { 224 | addVideoDownloadCoverBtn(item); 225 | } 226 | } 227 | }); 228 | }); 229 | } 230 | }); 231 | }, 2 * 1000); 232 | 233 | document.body.addEventListener('click', (e) => { 234 | if (e.target instanceof Element) { 235 | const btn = e.target.closest(`.${CLASS_CUSTOM_BUTTON}`); 236 | if (btn) { 237 | e.preventDefault(); 238 | if (btn.getAttribute("data-video-cover-download") == "true") { 239 | handleVideoCoverDownloadBtn(btn.parentElement!) 240 | return 241 | } 242 | onClickHandler(btn); 243 | } 244 | } 245 | }); 246 | --------------------------------------------------------------------------------