Download photos, videos, reels, stories, and profile pictures from Instagram with one click
// ==UserScript==
// @name Instagram Download Button
// @description Download photos, videos, reels, stories, and profile pictures from Instagram with one click
// @author KanashiiWolf
// @namespace https://wulf.nekoweb.org
// @homepageURL https://wulf.nekoweb.org
// @homepage https://wulf.nekoweb.org
// @license MIT
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiB2aWV3Qm94PSIwIDAgNTEyIDUxMiI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJpZyIgeDE9IjAlIiB5MT0iMTAwJSIgeDI9IjEwMCUiIHkyPSIwJSI+PHN0b3Agb2Zmc2V0PSIwJSIgc3R5bGU9InN0b3AtY29sb3I6I2ZlZGE3NSIvPjxzdG9wIG9mZnNldD0iMjUlIiBzdHlsZT0ic3RvcC1jb2xvcjojZmE3ZTFlIi8+PHN0b3Agb2Zmc2V0PSI1MCUiIHN0eWxlPSJzdG9wLWNvbG9yOiNkNjI5NzYiLz48c3RvcCBvZmZzZXQ9Ijc1JSIgc3R5bGU9InN0b3AtY29sb3I6Izk2MmZiZiIvPjxzdG9wIG9mZnNldD0iMTAwJSIgc3R5bGU9InN0b3AtY29sb3I6IzRmNWJkNSIvPjwvbGluZWFyR3JhZGllbnQ+PGZpbHRlciBpZD0iZHMiIGhlaWdodD0iMTMwJSI+PGZlR2F1c3NpYW5CbHVyIGluPSJTb3VyY2VBbHBoYSIgc3RkRGV2aWF0aW9uPSIzIi8+PGZlT2Zmc2V0IGR4PSIyIiBkeT0iNCIgcmVzdWx0PSJvYiIvPjxmZUNvbXBvbmVudFRyYW5zZmVyPjxmZUZ1bmNBIHR5cGU9ImxpbmVhciIgc2xvcGU9IjAuMyIvPjwvZmVDb21wb25lbnRUcmFuc2Zlcj48ZmVNZXJnZT48ZmVNZXJnZU5vZGUvPjxmZU1lcmdlTm9kZSBpbj0iU291cmNlR3JhcGhpYyIvPjwvZmVNZXJnZT48L2ZpbHRlcj48L2RlZnM+PHJlY3QgeD0iMTYiIHk9IjE2IiB3aWR0aD0iNDgwIiBoZWlnaHQ9IjQ4MCIgcng9IjEyOCIgZmlsbD0idXJsKCNpZykiLz48cmVjdCB4PSI5NiIgeT0iOTYiIHdpZHRoPSIzMjAiIGhlaWdodD0iMzIwIiByeD0iODgiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIyNiIgb3BhY2l0eT0iMC41NSIvPjxjaXJjbGUgY3g9IjM4MCIgY3k9IjE0MCIgcj0iMjAiIGZpbGw9IiNmZmYiIG9wYWNpdHk9IjAuNTUiLz48cGF0aCBkPSJNMjU2LDM5MiBMMTQ2LDI4MiBoNzAgVjEzMiBoODAgdjE1MCBoNzAgTDI1NiwzOTIgeiIgZmlsbD0iI2ZmZmZmZiIgZmlsdGVyPSJ1cmwoI2RzKSIvPjwvc3ZnPg==
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// @connect cdninstagram.com
// @connect instagram.com
// @connect fbcdn.net
// @match https://www.instagram.com/*
// @compatible firefox
// @version 1.19.1
// ==/UserScript==
/**
* Code started from:
* File: Instagram_Download_Button.user.js
* Project: Instagram Download Button
* Author: ZhiYu (y252328)
* URL: https://greasyfork.org/en/scripts/406535-instagram-download-button
* Source: https://github.com/y252328/Instagram_Download_Button
* License: MIT License
*/
(function () {
"use strict";
// =================
// = Options =
// =================
// Old method is faster than new method, but not work or unable get highest resolution media sometime
const disableNewUrlFetchMethod = false;
const prefetchAndAttachLink = false; // prefetch and add link into the button elements
const hoverToFetchAndAttachLink = true; // fetch and add link when hover the button
const replaceJpegWithJpg = false;
// On a multi-image/video post, the download button saves EVERY item rather
// than only the slide on screen. Set false to download just the current one
const downloadWholeCarousel = true;
// Gap between saves in a carousel batch; back-to-back anchor clicks trip the
// browser's automatic-multi-download block and files get dropped silently
const carouselDownloadGapMs = 600;
// Small overlay button on each carousel slide that saves just that image,
// alongside the action-bar button that saves the whole post
const perSlideButtons = true;
// === File name placeholders ===
// %id% : the poster id
// %datetime% : the media upload time
// %medianame% : the original media file name
// %postId% : the post id
// %mediaIndex% : the media index in multiple-media posts
const postFilenameTemplate = "%id%-%datetime%-%medianame%";
const storyFilenameTemplate = postFilenameTemplate;
// === Datetime placeholders ===
// %y%: year (4 digits)
// %m%: month (01-12)
// %d%: day (01-31)
// %H%: hour (00-23)
// %M%: min (00-59)
// %S%: sec (00-59)
const datetimeTemplate = "%y%%m%%d%_%H%%M%%S%";
// ==================
// Reels and IGTV share the post ID space; matching only /p/ meant a reel
// fell through to scanning page JSON for the first media PK it could find —
// frequently a DIFFERENT post, silently downloading the wrong file
const postIdPattern = /^\/(?:p|reel|reels|tv)\/([^/?]+)/;
// Instagram's own sub-routes share that namespace: the reel viewer links to
// /reels/audio/<id>/, and "audio" is a valid base64 string, so it decoded to
// a real-looking media ID (448387240) and sent the info API after unrelated
// media — which failed, dropping every reel into the legacy DOM fallback
const NON_SHORTCODE_SEGMENTS = new Set(["audio"]);
// Instagram localizes every aria-label, so the action bar is found by icon
// GEOMETRY instead. Same technique the save/play selectors already use.
// Captured from the live DOM 2026-08-03. Comment and Share both match on a
// PREFIX: Comment is the required condition that terminates findActionBars'
// walk-up, so an exact full-path match made a single nudged coordinate a
// silent, total, every-surface failure. Save keeps its full-polygon match —
// it only picks the insertion point, and losing it degrades to inserting
// after Share rather than to no buttons at all.
const ACTION_ICONS = { comment: "path[d^=\"M20.656 17.008\"]", share: "path[d^=\"M13.973 20.046 21.77 6.928\"]", save: "polygon[points=\"20 21 12 13.44 4 21 4 3 20 3 20 21\"]" };
// Instagram's web App ID, hardcoded as the default in yt-dlp/gallery-dl/
// instaloader; used only if it can't be scraped from the page
const WEB_APP_ID = "936619743392459";
// Shortcodes are positional base64 over this alphabet, so the numeric media
// ID is pure arithmetic — no network request. Verified against yt-dlp's own
// test fixtures (its `instagram://media?id=` value equals the decoded
// shortcode), instaloader, gallery-dl, and a Snowflake-timestamp check
const IG_B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
function shortcodeToMediaId(shortcode) {
if (!shortcode) return null;
// Private posts carry a 28-char tracking suffix; older posts can have a
// genuine shortcode shorter than 11 chars, so trim from the END
const code = shortcode.length > 28 ? shortcode.slice(0, -28) : shortcode;
let pk = 0n;
for (const ch of code) {
const index = IG_B64.indexOf(ch);
if (index < 0) return null;
pk = pk * 64n + BigInt(index); // PKs exceed Number.MAX_SAFE_INTEGER
}
return pk > 0n ? pk.toString() : null;
}
// 24x24 outline glyphs at ~2px stroke, matching Instagram's own action-bar
// icons. The old download glyph was a filled 0 0 477.867 viewBox and read
// noticeably heavier than its neighbours
var svgDownloadBtn = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" height="24" width="24"
fill="none" stroke="%color" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3v12"/>
<path d="m7 10 5 5 5-5"/>
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/>
</svg>`;
// Box with an outgoing arrow — the universal "open in new tab" glyph
// (the old two-overlapping-squares icon read as "copy")
var svgNewtabBtn = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" height="24" width="24"
fill="none" stroke="%color" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5"/>
<path d="M14 4h6v6"/>
<path d="m13 11 7-7"/>
</svg>`;
// Chain-link icon for the share button (matches the style of the other two)
const svgShareBtn = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" height="24" width="24"
fill="none" stroke="%color" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.5 14.5a3.5 3.5 0 0 0 5 0l3-3a3.5 3.5 0 0 0-5-5l-1 1"/>
<path d="M14.5 9.5a3.5 3.5 0 0 0-5 0l-3 3a3.5 3.5 0 0 0 5 5l1-1"/>
</svg>`;
// Embed-fixer domains for share links (like fxtwitter/fxbsky in the sibling
// scripts); "custom" prompts for any domain
const SHARE_DOMAINS = ["instagram.com", "ddinstagram.com", "kkinstagram.com"];
// What each button does, spelled out — the icon-only buttons were the main
// complaint. Shown as a tooltip and read by screen readers.
// NOTE: must be declared ABOVE the scan/observer bootstrap — scan-time code
// reads it, and a TDZ throw there aborts the whole IIFE
const BUTTON_LABELS = {
"newtab-btn": "Open media in a new tab (full size)",
"download-btn": "Download this media",
"share-btn": "Copy share link",
};
// Feedback states for the buttons — the icon-only buttons gave no hint of
// what happened after a click
const styleEl = document.createElement("style");
styleEl.textContent = `
.custom-btn { display: inline-flex; align-items: center; opacity: 0.85; transition: opacity 0.15s, transform 0.1s; }
.custom-btn:hover { opacity: 1; transform: scale(1.1); }
.custom-btn:focus-visible { outline: 2px solid #0095f6; outline-offset: 2px; border-radius: 4px; }
.igdl-slide-btn {
position: absolute; top: 10px; right: 10px; z-index: 20;
width: 32px; height: 32px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.55); cursor: pointer;
opacity: 0; transition: opacity 0.15s, background 0.15s;
}
.igdl-slide-host:hover .igdl-slide-btn, .igdl-slide-btn:focus-visible { opacity: 1; }
.igdl-slide-btn:hover { background: rgba(0,0,0,0.8); }
.igdl-slide-btn svg { width: 18px; height: 18px; fill: none; stroke: #fff; }
.igdl-loading svg { animation: igdl-spin 1s linear infinite; }
.igdl-success svg { stroke: #4caf50 !important; }
.igdl-failed svg { stroke: #ed4956 !important; }
@keyframes igdl-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.igdl-bar-btn { display: flex; align-items: center; justify-content: center; cursor: pointer; color: inherit; opacity: 0.9; transition: opacity 0.15s, transform 0.1s; }
.igdl-bar-btn:hover { opacity: 1; transform: scale(1.06); }
.igdl-bar-btn:focus-visible { outline: 2px solid #0095f6; outline-offset: 2px; border-radius: 8px; }
.igdl-bar-btn svg { fill: none; stroke: currentColor; }
`;
document.head.appendChild(styleEl);
// Flashes a temporary state class on a button (success ✓ / failed ✗ colors)
function flashState(btn, state, ms = 1800) {
if (!btn?.classList) return;
btn.classList.remove("igdl-loading", "igdl-success", "igdl-failed");
if (state) {
btn.classList.add(state);
if (state !== "igdl-loading") {
setTimeout(() => btn.classList.remove(state), ms);
}
}
}
GM_registerMenuCommand("Set share-link domain", () => {
const current = GM_getValue("share_domain", "instagram.com");
const input = prompt(
`Share-link domain (current: ${current})\nOptions: ${SHARE_DOMAINS.join(", ")} — or type any custom domain:`,
current,
);
if (input === null) return;
const domain = input.trim().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
if (/^[a-z0-9.-]+$/i.test(domain)) {
GM_setValue("share_domain", domain);
} else {
alert("IGDL: that doesn't look like a valid domain.");
}
});
var preUrl = "";
document.addEventListener("keydown", keyDownHandler);
// The last button a click would actually work on. Taking the last in DOM
// order — what this did before — is dead on /reels/: all four mounted rails
// now carry buttons, so the last one belongs to the last MOUNTED reel,
// which is off screen and rejected by postOnClicked's own guard every time.
// Scans backwards and returns the last button whose resolved media scope
// passes the SAME predicate that guard uses. Non-bar buttons (the profile
// and story injectors' `custom-btn` anchors) are never arbitrated: they
// don't go through the post path at all
function lastLiveButton(className) {
const buttons = document.getElementsByClassName(className);
for (let i = buttons.length - 1; i >= 0; i--) {
const btn = buttons[i];
if (!btn.classList.contains("igdl-bar-btn")) return btn;
if (isLiveMediaScope(postGetArticleNode(btn))) return btn;
}
return null;
}
function keyDownHandler(event) {
if (window.location.href === "https://www.instagram.com/") return;
const mockEventTemplate = {
stopPropagation: function () { },
preventDefault: function () { }
};
const fire = (className) => {
const btn = lastLiveButton(className);
if (!btn) return;
let mockEvent = { ...mockEventTemplate };
mockEvent.currentTarget = btn;
if (prefetchAndAttachLink || hoverToFetchAndAttachLink) onMouseInHandler(mockEvent);
onClickHandler(mockEvent);
};
if (event.altKey && (event.code === "KeyK" || event.key == "k")) fire("download-btn");
if (event.altKey && (event.code === "KeyI" || event.key == "i")) fire("newtab-btn");
}
function queryHas(root, selector, has) {
let nodes = root.querySelectorAll(selector);
for (let i = 0; i < nodes.length; ++i) {
let currentNode = nodes[i];
if (currentNode.querySelector(has)) {
return currentNode;
}
}
return null;
}
function isRenderedNode(el) {
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
}
// Which reel the URL names is the one centred in the viewport, so a control
// is "live" only if its own box spans the viewport's vertical midpoint.
// Deliberately NOT isRenderedNode: every mounted rail reports a full-size
// box (60x910, live-measured) whether or not it is on screen, so a size
// test alone detects nothing. Kept as a separate predicate — isRenderedNode
// also gates action-bar injection, which must stay eager: scrolling alone
// fires no DOM mutation, so a viewport-gated injector could leave a rail
// you scroll to with no buttons at all
function isActiveControl(el) {
const r = el.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return false;
const midY = window.innerHeight / 2;
return r.top < midY && r.bottom > midY;
}
// Would a click on a button owning this resolved media scope actually
// download the right thing? Only reel rails are ambiguous: several are
// mounted at once and their identity comes from location.pathname, so a
// scrolled-away rail would resolve to whichever reel is on screen. Feed
// posts and the modal resolve to their own <article>, which names itself,
// and are always live regardless of scroll position — resolveMediaScope
// returns the <article> for those and a video-containing div for rails, so
// closest("article") is exactly the discriminator.
//
// An UNRESOLVED scope (null) is not rejected here: it isn't an off-screen
// control, it's a failure to find anything, and it fails downstream with
// its own error. postOnClicked and keyDownHandler share this one predicate
// so the click guard and the keyboard shortcut's button choice cannot drift
function isLiveMediaScope(scope) {
if (!scope || !scope.closest) return true;
return !!scope.closest("article") || isActiveControl(scope);
}
// Exactly one icon of this kind in the bar. The raw count is the fast path;
// only when it disagrees do we look at layout, counting the icons that are
// actually rendered. Instagram's post modal ships a HIDDEN duplicate Share
// control (a 0x0 box beside the real 24x24 one), so a raw count alone
// rejects that bar outright — the rendered count is what rescues it, and it
// stays correct if a hidden duplicate ever appears for Comment too
function hasOneIcon(bar, selector, isVisible) {
const icons = bar.querySelectorAll(selector);
if (icons.length === 1) return true;
let rendered = 0;
for (const icon of icons) {
const svgEl = icon.closest("svg");
if (svgEl && isVisible(svgEl)) rendered++;
}
return rendered === 1;
}
// Comment is the required condition that terminates the walk-up below, so
// if Instagram ever changes that glyph EVERY surface resolves zero bars and
// the feature disappears with no diagnostic at all. Warn once per page when
// the page clearly has action bars (a Comment icon exists) but none
// resolved — same instinct as findAppId's fallback warning
let noBarsWarned = false;
// The action bar is the nearest ancestor of a Share icon that also holds a
// Comment icon. Bounded to 6 hops, never <body>/<html>, at most 12
// children, and required to hold exactly one Comment icon and exactly one
// Share icon (rendered counts when the raw ones disagree). Without those
// guards the walk-up escapes into page-level wrappers (a 1707x4421 div, and
// <body> itself)
//
// The left-nav Messages control reuses the Share glyph, so it is a candidate
// too. The `closest("nav")` test below is NOT what rejects it: live DOM
// shows that icon reporting `inNav=false`, i.e. Instagram's sidebar is not a
// <nav> element, so that check is inert today and kept only as cheap
// defence if they ever add one. What actually rejects it is the walk-up —
// it has no Comment icon within 6 hops — plus isVisible on the surfaces
// where it renders 0x0. Instagram ships many hidden duplicates of these
// controls (a story viewer shows 6 Share icons, 5 of them 0x0), which is
// also why the counts below fall back to rendered-only
//
// Ordered cheapest-first: this runs on every rAF-batched observer tick, so
// isVisible's getBoundingClientRect() (a forced synchronous reflow) is
// deliberately last — after the free DOM-only guards below have already
// ruled out everything they can, and after `skip` (steady-state callers
// pass one that's true for every bar that already has our buttons) has had
// a chance to reject the bar without ever touching layout. `skip` sits
// ahead of the share-icon count for the same reason: the post modal's bar
// permanently carries 2 share icons (see below), so without this ordering
// an already-injected modal bar would keep paying for the rendered-count
// fallback's layout reads on every tick, defeating the point of `skip`
function findActionBars(root = document, isVisible = isRenderedNode, skip = null) {
const bars = [];
let skipped = 0;
for (const path of root.querySelectorAll(ACTION_ICONS.share)) {
const svg = path.closest("svg");
if (!svg || svg.closest("nav")) continue;
let bar = svg.closest("div[role=\"button\"], button, a, span") || svg.parentElement;
for (let hop = 0; hop < 6 && bar; hop++) {
if (bar.querySelector(ACTION_ICONS.comment)) break;
bar = bar.parentElement;
}
if (!bar || !bar.querySelector(ACTION_ICONS.comment)) continue;
// <body>/<html> enclose the whole page, so they can satisfy every count
// below by coincidence — a stray Share glyph outside any bar on a page
// with one rendered post is enough. Never a bar, whatever the counts say
if (bar === document.body || bar === document.documentElement) continue;
if (bar.children.length > 12) continue;
if (skip && skip(bar)) { skipped++; continue; }
// Both icon counts get the raw-then-rendered treatment (see hasOneIcon).
// Counting Comment is what rejects a wrapper enclosing several real
// bars: it holds one Comment per enclosed bar, and the rendered count
// only falls back to 1 if all but one of those bars is hidden
if (!hasOneIcon(bar, ACTION_ICONS.comment, isVisible)) continue;
if (!hasOneIcon(bar, ACTION_ICONS.share, isVisible)) continue;
if (!isVisible(svg)) continue;
if (!bars.includes(bar)) bars.push(bar);
}
// Nothing resolved and nothing was skipped as already-injected, yet the
// page does render Comment icons: the geometry above has gone stale
if (!noBarsWarned && bars.length === 0 && skipped === 0 && root.querySelector(ACTION_ICONS.comment)) {
noBarsWarned = true;
console.warn("IGDL: Instagram's Comment icon is on the page but no action bar resolved — the icon geometry in ACTION_ICONS has probably changed");
}
return bars;
}
// The bar's direct child holding a given icon — the cell, not the icon
function barCellFor(bar, selector) {
const icon = bar.querySelector(selector);
if (!icon) return null;
for (const child of bar.children) {
if (child.contains(icon)) return child;
}
return null;
}
// Row bars get the buttons after Share: on the feed Save sits in a separate
// right-aligned container and on the modal Save is the bar's last child, so
// one rule gives the same result on both. Column rails put them after Save,
// ahead of the overflow menu
function barInsertPoint(bar) {
const column = getComputedStyle(bar).flexDirection === "column";
const shareCell = barCellFor(bar, ACTION_ICONS.share);
const saveCell = barCellFor(bar, ACTION_ICONS.save);
return column ? (saveCell || shareCell) : shareCell;
}
const BAR_BUTTONS = [{ className: "newtab-btn", svg: svgNewtabBtn }, { className: "download-btn", svg: svgDownloadBtn }, { className: "share-btn", svg: svgShareBtn }];
// Sized by cloning a native reference cell rather than hardcoding numbers
// per surface: 40x40 in row bars, 24x24 with the rail's own vertical margin
// (which is where the rail's 52px pitch comes from). injectIntoBar passes
// the SAME reference — the native anchor cell — for all three buttons, so
// they come out uniform rather than each copying the button before it
function buildBarButton(className, svg, reference) {
const cell = document.createElement("div");
cell.className = "igdl-bar-btn " + className;
cell.setAttribute("role", "button");
cell.setAttribute("tabindex", "0");
const label = BUTTON_LABELS[className] || className;
cell.setAttribute("title", label);
cell.setAttribute("aria-label", label);
cell.innerHTML = svg.replace("%color", "currentColor");
if (reference) {
const ref = getComputedStyle(reference);
for (const prop of ["width", "height", "marginTop", "marginRight", "marginBottom", "marginLeft"]) {
if (ref[prop] && ref[prop] !== "auto") cell.style[prop] = ref[prop];
}
}
cell.onclick = onClickHandler;
cell.onkeydown = (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); cell.click(); }
};
if (hoverToFetchAndAttachLink && className !== "share-btn") {
cell.onmouseenter = onMouseInHandler;
}
return cell;
}
// Returns true when this call actually injected. Dedup is per bar, replacing
// the old global custom-btn count — that count is why every surface after
// the first was skipped
function injectIntoBar(bar) {
if (bar.querySelector(".igdl-bar-btn")) return false;
const anchor = barInsertPoint(bar);
if (!anchor) return false;
let after = anchor;
for (const { className, svg } of BAR_BUTTONS) {
const cell = buildBarButton(className, svg, anchor);
after.after(cell);
after = cell;
}
return true;
}
// Per-surface dedup marker check. Extracted so the tests exercise the real
// condition rather than a copy of it
function surfaceNeedsButtons(marker) {
return !document.querySelector(`.custom-btn.${marker}`);
}
function scanForButtons() {
const curUrl = window.location.href;
const profileSelector = "header section svg circle";
const playSvgPathSelector = 'path[d="M5.888 22.5a3.46 3.46 0 0 1-1.721-.46l-.003-.002a3.451 3.451 0 0 1-1.72-2.982V4.943a3.445 3.445 0 0 1 5.163-2.987l12.226 7.059a3.444 3.444 0 0 1-.001 5.967l-12.22 7.056a3.462 3.462 0 0 1-1.724.462Z"]';
const pauseSvgPathSelector = 'path[d="M15 1c-3.3 0-6 1.3-6 3v40c0 1.7 2.7 3 6 3s6-1.3 6-3V4c0-1.7-2.7-3-6-3zm18 0c-3.3 0-6 1.3-6 3v40c0 1.7 2.7 3 6 3s6-1.3 6-3V4c0-1.7-2.7-3-6-3z"]';
let rgb = getComputedStyle(document.body).backgroundColor.match(/[.?\d]+/g);
let iconColor = (rgb[0] * 0.299 + rgb[1] * 0.587 + rgb[2] * 0.114) <= 150 ? "white" : "black";
// clear all custom buttons when url changing
if (preUrl !== curUrl) {
while (document.getElementsByClassName("custom-btn").length !== 0) {
document.getElementsByClassName("custom-btn")[0].remove();
}
// Slide overlays are tracked separately (they must not count toward the
// custom-btn guards below), so clear them here too
document.querySelectorAll(".igdl-slide-btn").forEach((b) => b.remove());
document.querySelectorAll(".igdl-slide-host").forEach((h) => h.classList.remove("igdl-slide-host"));
document.querySelectorAll(".igdl-bar-btn").forEach((b) => b.remove());
}
// One rule for every surface: feed posts, the profile-expanded modal, the
// /reels/ rail, and a feed post expanded into reels mode all expose the
// same native action bar. The skip predicate rejects already-injected
// bars before findActionBars reaches its layout-forcing visibility
// check, so a steady-state tick (the common case on a busy feed) never
// pays for a reflow per Share icon; injectIntoBar's own dedup guard is
// still the one actually relied on for correctness
findActionBars(document, isRenderedNode, (bar) => bar.querySelector(".igdl-bar-btn")).forEach(injectIntoBar);
// check profile — the PROFILE PAGE owns this injector, and nothing else.
// Two positive conditions, because `surfaceNeedsButtons` only dedupes and
// cannot decide surface ownership the way the old global custom-btn count
// accidentally did: not a story page, and not a media permalink. A post
// opened from a profile grid leaves the profile header mounted BEHIND the
// modal, where `header section svg circle` still matches — shortcodeFrom
// is non-null for exactly /p/, /reel/, /reels/<code> and /tv/, which is
// that whole family.
//
// The story test matches the story injector's exactly — a substring test
// for "stor" against the whole URL also matched any username containing
// it (instagram.com/storyofmylife/), silently denying those profiles their
// buttons
if (!location.pathname.includes("/stories/") && !shortcodeFrom(location.pathname)) {
const profileAnchor = document.querySelector(profileSelector);
if (profileAnchor && surfaceNeedsButtons("igdl-profile")) {
addCustomBtn(profileAnchor, iconColor, append2Header, "igdl-profile");
}
}
// Per-slide overlays: independent of the action-bar buttons above, so they
// work on whichever surface the carousel is rendered on
if (perSlideButtons) addSlideButtons();
// check story — the STORY/HIGHLIGHT VIEWER owns this injector. The URL
// gate is not optional: the play/pause glyph is Instagram's generic media
// control and renders on reels and video posts too, so without it pausing
// a reel appends three white anchors four DOM levels above the player.
// `/stories/` covers highlights (/stories/highlights/<id>/) and matches
// how onClickHandler already routes story clicks
if (location.pathname.includes("/stories/") && surfaceNeedsButtons("igdl-story")) {
let playPauseSvg = queryHas(document, "svg", playSvgPathSelector) || queryHas(document, "svg", pauseSvgPathSelector);
if (playPauseSvg) {
addCustomBtn(playPauseSvg.parentNode, "white", append2Story, "igdl-story");
}
}
preUrl = curUrl;
}
// Upstream polled on a 500ms timer (its own TO-DO asked for an observer).
// rAF-batched MutationObserver reacts immediately to new posts and costs
// nothing while the page is idle; the timeout backstop covers hidden tabs
// where rAF never fires
let scanScheduled = false;
let scanBackstop = null;
const runScan = () => {
scanScheduled = false;
clearTimeout(scanBackstop);
scanBackstop = null;
try {
scanForButtons();
} catch (e) {
console.warn("IGDL: scan failed", e);
}
};
new MutationObserver(() => {
if (scanScheduled) return;
scanScheduled = true;
requestAnimationFrame(runScan);
scanBackstop = setTimeout(runScan, 300);
}).observe(document.body, { childList: true, subtree: true });
// Initial sweep goes through the guarded runner — an uncaught throw here
// would abort the rest of the IIFE's setup
runScan();
// The tightest ancestor of a slide that owns a dot indicator. Scoping
// matters: with no <article> on the page the walk to MAIN would otherwise
// pick up every post's dots at once
function findCarouselScope(slide) {
let node = slide.parentElement;
while (node && node !== document.body) {
if (node.querySelectorAll("div._acnb").length > 1) return node;
node = node.parentElement;
}
return null;
}
// Absolute index of the slide a per-slide button sits on.
// Ordinal position CANNOT be used: Instagram virtualizes the track into a
// prev/current/next window, so from slide 3 onward the visible slide is
// always ordinal 1 — which made slides 3 and 4 both download image 2. The
// dot indicator carries the true index, so it leads
function resolveSlideIndex(slide, itemCount) {
const scope = findCarouselScope(slide);
if (scope) {
const dots = [...scope.querySelectorAll("div._acnb")];
if (dots.length > 1) {
const counts = new Map();
for (const dot of dots) counts.set(dot.className, (counts.get(dot.className) || 0) + 1);
let common = null;
let most = -1;
for (const [cls, n] of counts) {
if (n > most) { most = n; common = cls; }
}
const odd = dots.findIndex((dot) => dot.className !== common);
if (odd !== -1) return odd;
}
}
const fromUrl = location.search.match(/[?&]img_index=(\d+)/);
if (fromUrl) {
const index = Number(fromUrl[1]) - 1;
if (index >= 0) return index;
}
// Ordinal only when the ENTIRE carousel is rendered (no windowing)
const siblings = [...slide.parentElement.children]
.filter((el) => el.querySelector("img, video"));
if (itemCount && siblings.length === itemCount) {
const ordinal = siblings.indexOf(slide);
if (ordinal >= 0) return ordinal;
}
return 0;
}
// Per-slide overlay button. Only the on-screen slide is clickable, so the
// index is resolved at click time by resolveSlideIndex
function addSlideButtons() {
const slides = document.querySelectorAll('ul li[style*="translateX"]');
slides.forEach((slide) => {
if (!slide.querySelector("img, video")) return;
if (slide.querySelector(".igdl-slide-btn")) return;
// Deliberately its own class, not `custom-btn` or `igdl-bar-btn`: slide
// overlays are unrelated to the action-bar/profile/story dedup markers
// and are tracked + cleared independently on navigation
const btn = document.createElement("div");
btn.className = "igdl-slide-btn";
btn.innerHTML = svgDownloadBtn.replace("%color", "#fff");
btn.setAttribute("title", "Download just this image");
btn.setAttribute("aria-label", "Download just this image");
btn.setAttribute("role", "button");
btn.setAttribute("tabindex", "0");
btn.onclick = async (e) => {
e.preventDefault();
e.stopPropagation();
try {
const articleNode = postGetArticleNode(slide);
if (!articleNode) return;
flashState(btn, "igdl-loading");
const all = await getUrlFromInfoApi(articleNode, 0, true);
const itemCount = Array.isArray(all) ? all.length : 0;
let index = resolveSlideIndex(slide, itemCount);
if (itemCount && index >= itemCount) index = itemCount - 1;
const url = (Array.isArray(all) && all[index])
|| await getUrlFromInfoApi(articleNode, index)
|| (await postGetUrl(btn, articleNode)).url;
if (!url) {
flashState(btn, "igdl-failed");
return;
}
let mediaName = url.split("?")[0].split("/").pop();
mediaName = mediaName.substring(0, mediaName.lastIndexOf(".")) || mediaName;
const timeNode = articleNode.querySelector("time");
const datetime = new Date(timeNode ? timeNode.getAttribute("datetime") : Date.now());
let poster = articleNode.querySelector("header a") || findPostName(articleNode);
poster = poster?.getAttribute
? poster.getAttribute("href").replace(/\//g, "")
: String(poster ?? "instagram");
const filename = filenameFormat(
postFilenameTemplate, poster, datetime, mediaName, findPostId(articleNode), index,
);
downloadResource(url, filename, btn);
} catch (err) {
console.warn("IGDL: per-slide download failed", err);
flashState(btn, "igdl-failed");
}
};
// The overlay needs a positioned ancestor, and hover reveals it
const host = slide.querySelector("div") || slide;
if (getComputedStyle(host).position === "static") host.style.position = "relative";
host.classList.add("igdl-slide-host");
host.appendChild(btn);
});
}
function append2Header(node, btn) {
node.parentNode.parentNode.parentNode.appendChild(btn, node.parentNode.parentNode);
}
function append2Story(node, btn) {
node.parentNode.parentNode.parentNode.append(btn);
}
function addCustomBtn(node, iconColor, appendNode, marker) {
// newtab + download + share, in that order
let newtabBtn = createCustomBtn(svgNewtabBtn, iconColor, "newtab-btn", "16px");
if (marker) newtabBtn.classList.add(marker);
appendNode(node, newtabBtn);
let downloadBtn = createCustomBtn(svgDownloadBtn, iconColor, "download-btn", "14px");
if (marker) downloadBtn.classList.add(marker);
appendNode(node, downloadBtn);
let shareBtn = createCustomBtn(svgShareBtn, iconColor, "share-btn", "14px");
if (marker) shareBtn.classList.add(marker);
appendNode(node, shareBtn);
if (prefetchAndAttachLink) {
onMouseInHandler({ currentTarget: newtabBtn });
onMouseInHandler({ currentTarget: downloadBtn });
}
}
function createCustomBtn(svg, iconColor, className, marginLeft) {
let newBtn = document.createElement("a");
newBtn.innerHTML = svg.replace("%color", iconColor);
newBtn.setAttribute("class", "custom-btn " + className);
newBtn.setAttribute("target", "_blank");
newBtn.setAttribute("style", "cursor: pointer;margin-left: " + marginLeft + ";margin-top: 8px;z-index: 999;");
newBtn.onclick = onClickHandler;
// Same exclusion, same idiom as buildBarButton: every caller passes an
// exact class name, so an exact comparison is the one to use
if (hoverToFetchAndAttachLink && className !== "share-btn") {
newBtn.onmouseenter = onMouseInHandler;
}
const label = BUTTON_LABELS[className] || className;
newBtn.setAttribute("title", label);
newBtn.setAttribute("aria-label", label);
newBtn.setAttribute("role", "button");
newBtn.setAttribute("tabindex", "0");
return newBtn;
}
function onClickHandler(e) {
// handle button click
let target = e.currentTarget;
e.stopPropagation();
e.preventDefault();
if (target.classList.contains("share-btn")) {
copyShareLink(target);
return;
}
// Bar buttons are only ever created by injectIntoBar on a real post/reel
// action bar, so they always name a post — never let header containment
// (or a stray "stories" URL match) misroute one through the profile or
// story path and download the wrong thing
if (target.classList.contains("igdl-bar-btn")) {
postOnClicked(target);
return;
}
if (window.location.pathname.includes("stories")) {
storyOnClicked(target);
} else if (document.querySelector("header") && document.querySelector("header").contains(target)) {
profileOnClicked(target);
} else {
postOnClicked(target);
}
}
// ================================
// ==== Share ====
// ================================
// Copies the canonical URL for what the button sits on (post permalink,
// story URL, or profile URL), with the configured domain swapped in —
// same feature as the fxtwitter/fxbsky share buttons in the sibling scripts
async function copyShareLink(target) {
try {
// Same routing as onClickHandler: story and profile share the page URL,
// posts share their canonical permalink. A bar button always takes the
// post path — see onClickHandler for why
let url;
const isBarButton = target.classList.contains("igdl-bar-btn");
const header = document.querySelector("header");
if (!isBarButton && (window.location.pathname.includes("stories") || (header && header.contains(target)))) {
url = window.location.href;
} else {
const articleNode = postGetArticleNode(target);
const postId = articleNode ? findPostId(articleNode) : null;
url = postId ? `https://www.instagram.com/p/${postId}/` : window.location.href;
}
const domain = GM_getValue("share_domain", "instagram.com");
const shared = new URL(url);
if (/^[a-z0-9.-]+$/i.test(domain)) {
// Only instagram.com canonically lives under www.; embed-fixer
// domains (ddinstagram etc.) don't serve a www. subdomain
const bare = domain.replace(/^www\./, "");
shared.hostname = bare === "instagram.com" ? "www.instagram.com" : bare;
}
const text = shared.href;
try {
await navigator.clipboard.writeText(text);
} catch {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
ta.remove();
}
flashState(target, "igdl-success");
} catch (e) {
console.warn("IGDL: share copy failed", e);
flashState(target, "igdl-failed");
}
}
function onMouseInHandler(e) {
let target = e.currentTarget;
if (!prefetchAndAttachLink && !hoverToFetchAndAttachLink) return;
// Bar buttons always take the post path — see onClickHandler for why
if (target.classList.contains("igdl-bar-btn")) {
postOnMouseIn(target);
return;
}
if (window.location.pathname.includes("stories")) {
storyOnMouseIn(target);
} else if (document.querySelector("header") && document.querySelector("header").contains(target)) {
profileOnMouseIn(target);
} else {
postOnMouseIn(target);
}
}
// ================================
// ==== Profile ====
// ================================
function profileOnMouseIn(target) {
let url = profileGetUrl(target);
target.setAttribute("href", url);
}
function profileOnClicked(target) {
// extract profile picture url and download or open it
let url = profileGetUrl(target);
if (url.length > 0) {
// check url
if (target.getAttribute("class").includes("download-btn")) {
// generate filename
const filename = document.querySelector("header h2").textContent;
downloadResource(url, filename, target);
} else {
// open url in new tab
openResource(url);
}
}
}
function profileGetUrl(_target) {
let img = document.querySelector("header img");
let url = img.getAttribute("src");
return url;
}
// ================================
// ==== Post ====
// ================================
// Hover prefetch is best-effort: it must never surface as an unhandled
// rejection (the click path reports failures itself)
async function postOnMouseIn(target) {
try {
let articleNode = postGetArticleNode(target);
let { url } = await postGetUrl(target, articleNode);
if (url) target.setAttribute("href", url);
} catch (e) {
console.debug("IGDL: hover prefetch failed", e);
}
}
// Saves every item of a multi-media post. Sequential with a gap: firing
// several anchor clicks at once trips the browser's automatic-multi-download
// block, which drops files silently. Returns false if it couldn't run, so
// the caller can fall back to the single-slide path
async function downloadCarousel(target, articleNode, urls) {
const datetimeNode = articleNode.querySelector("time");
const datetime = new Date(datetimeNode ? datetimeNode.getAttribute("datetime") : Date.now());
let posterName = articleNode.querySelector("header a") || findPostName(articleNode);
posterName = posterName?.getAttribute
? posterName.getAttribute("href").replace(/\//g, "")
: String(posterName ?? "instagram");
const postId = findPostId(articleNode);
flashState(target, "igdl-loading");
let failures = 0;
for (let i = 0; i < urls.length; i++) {
const url = urls[i];
let mediaName = url.split("?")[0].split("\\").pop().split("/").pop();
mediaName = mediaName.substring(0, mediaName.lastIndexOf(".")) || mediaName;
const filename = filenameFormat(
postFilenameTemplate, posterName, datetime, mediaName, postId, i,
);
// Await each save so the gap below actually separates them
const ok = await new Promise((resolve) => {
downloadResource(url, filename, null, resolve);
});
if (!ok) failures++;
if (i < urls.length - 1) {
await new Promise((r) => setTimeout(r, carouselDownloadGapMs));
}
}
flashState(target, failures ? "igdl-failed" : "igdl-success");
if (failures) {
console.warn(`IGDL: ${failures} of ${urls.length} carousel items failed`);
}
return true;
}
async function postOnClicked(target) {
try {
// extract url from target post and download or open it
let articleNode = postGetArticleNode(target);
// See isLiveMediaScope for why only rails are arbitrated. Checked
// against the resolved media scope (the rail — live-measured at ~910px
// tall, nearly the full viewport), not the ~24px button: the button
// sits somewhere inside that tall column and is not reliably positioned
// across the exact viewport midpoint even on an active rail, so testing
// it directly would reject genuine clicks
if (!isLiveMediaScope(articleNode)) {
console.warn("IGDL: refusing to download from an off-screen control");
flashState(target, "igdl-failed");
return;
}
// A post-level button on a multi-media post should grab the whole set
if (downloadWholeCarousel
&& target.getAttribute("class").includes("download-btn")
&& articleNode
&& articleNode.querySelectorAll("li[style][class]").length > 0
&& !disableNewUrlFetchMethod) {
const all = await getUrlFromInfoApi(articleNode, 0, true);
if (Array.isArray(all) && all.length > 1) {
await downloadCarousel(target, articleNode, all);
return;
}
}
let { url, mediaIndex } = await postGetUrl(target, articleNode);
// download or open media url
if (url && url.length > 0) {
// check url
if (target.getAttribute("class").includes("download-btn")) {
let mediaName = url
.split("?")[0]
.split("\\")
.pop()
.split("/")
.pop();
mediaName = mediaName.substring(0, mediaName.lastIndexOf("."));
// The reel viewer renders no <time> and no header anchor, and
// findPostName can hand back a bare string rather than an element —
// the same two guards downloadCarousel already carries
const datetimeNode = articleNode.querySelector("time");
let datetime = new Date(datetimeNode ? datetimeNode.getAttribute("datetime") : Date.now());
let posterName = articleNode.querySelector("header a") || findPostName(articleNode);
posterName = posterName?.getAttribute
? posterName.getAttribute("href").replace(/\//g, "")
: String(posterName ?? "instagram");
let postId = findPostId(articleNode);
let filename = filenameFormat(postFilenameTemplate, posterName, datetime, mediaName, postId, mediaIndex);
downloadResource(url, filename, target);
} else {
// open url in new tab
openResource(url);
}
} else {
console.warn("IGDL: could not resolve a media URL for this post");
flashState(target, "igdl-failed");
}
} catch (e) {
// Pass the value itself — string throws have no .stack
console.warn("IGDL: uncaught in postOnClicked():", e);
flashState(target, "igdl-failed");
return null;
}
}
function postGetArticleNode(target) {
// Bar buttons resolve through their own action bar, which is the only
// reliable handle on the reel overlay — it sits outside <main>, so the
// ancestor walk below returns null there
const bar = target.closest ? target.closest(".igdl-bar-btn")?.parentElement : null;
if (bar) {
const scope = resolveMediaScope(bar);
if (scope) return scope;
}
let articleNode = target;
while (articleNode && articleNode.tagName !== "ARTICLE" && articleNode.tagName !== "MAIN") {
articleNode = articleNode.parentNode;
}
return articleNode;
}
// Which carousel slide is currently showing. Delegates to the same resolver
// the per-slide buttons use — scoped dots first, then the URL's img_index.
// Geometry was removed after live testing: Instagram translates the TRACK,
// not the slides, so every slide reports translateX(0) and the derived index
// was always 0. Ordinal position is equally unusable — the carousel is
// virtualized into a prev/current/next window, so the visible slide sits at
// ordinal 0-1 no matter how far along the strip it is
function findCarouselIndex(articleNode) {
const slide = articleNode.querySelector('ul li[style*="translateX"]');
if (slide) return resolveSlideIndex(slide, 0);
const fromUrl = location.search.match(/[?&]img_index=(\d+)/);
if (fromUrl) {
const index = Number(fromUrl[1]) - 1; // img_index is 1-based
if (index >= 0) return index;
}
return null;
}
async function postGetUrl(target, articleNode) {
// meta[property="og:video"]
let list = articleNode.querySelectorAll("li[style][class]");
let url = null;
let mediaIndex = 0;
if (list.length === 0) {
// single img or video
if (!disableNewUrlFetchMethod) url = await getUrlFromInfoApi(articleNode);
if (url === null) {
let videoElem = articleNode.querySelector("video");
if (videoElem) {
// media type is video
url = videoElem.getAttribute("src");
if (videoElem.hasAttribute("videoURL")) {
url = videoElem.getAttribute("videoURL");
} else if (url === null || url.includes("blob")) {
url = await fetchVideoURL(articleNode, videoElem);
}
} else if (articleNode.querySelector("article div[role] div > img")) {
// media type is image
url = articleNode.querySelector("article div[role] div > img").getAttribute("src");
} else {
console.log("Err: not find media at handle post single");
}
}
} else {
// multiple imgs or videos
const postView = location.pathname.startsWith("/p/");
mediaIndex = findCarouselIndex(articleNode);
if (mediaIndex === null) {
// Degrade to the first slide rather than throwing — a failed index
// read shouldn't make the button do nothing at all
console.warn("IGDL: could not tell which carousel slide is showing — using the first");
mediaIndex = 0;
}
if (!disableNewUrlFetchMethod) url = await getUrlFromInfoApi(articleNode, mediaIndex);
if (url === null) {
const listElements = [...articleNode.querySelectorAll(`:scope > div > div:nth-child(${postView ? 1 : 2}) > div > div:nth-child(1) ul li[style*="translateX"]`)];
const listElementWidth = Math.max(...listElements.map(element => element.clientWidth));
const positionsMap = listElements.reduce((result, element) => {
const position = Math.round(Number(element.style.transform.match(/-?(\d+)/)[1]) / listElementWidth);
return { ...result, [position]: element };
}, {});
// Virtualized carousels only render nearby slides, so the map can
// miss the requested index — that used to throw on the next line
const node = positionsMap[mediaIndex];
const videoElem = node?.querySelector("video");
if (videoElem) {
// media type is video
url = videoElem.getAttribute("src");
if (videoElem.hasAttribute("videoURL")) {
url = videoElem.getAttribute("videoURL");
} else if (url === null || url.includes("blob")) {
url = await fetchVideoURL(articleNode, videoElem);
}
} else if (node?.querySelector("img")) {
// media type is image
url = node.querySelector("img").getAttribute("src");
} else {
console.warn(`IGDL: no rendered slide for carousel index ${mediaIndex}`);
}
}
}
return { url, mediaIndex };
}
function findHighlightsIndex() {
let currentDivProgressbarDiv = document.querySelector('div[style^="transform"]').parentElement;
let progressbarRootDiv = currentDivProgressbarDiv.parentElement;
let progressbarDivs = progressbarRootDiv.children;
return Array.from(progressbarDivs).indexOf(currentDivProgressbarDiv);
}
// Bounded session caches (Maps iterate in insertion order → evict oldest;
// upstream used unbounded plain objects that grew for the whole session)
const infoCache = new Map(); // key: media id, value: info json
const mediaIdCache = new Map(); // key: post id, value: media id
function cacheBounded(map, key, value) {
map.set(key, value);
if (map.size > 100) map.delete(map.keys().next().value);
}
// returnAll: resolve EVERY item in the post (carousel download) instead of
// just the slide at mediaIdx
async function getUrlFromInfoApi(articleNode, mediaIdx = 0, returnAll = false) {
// return media url if found else return null
// fetch flow:
// 1. find post id
// 2. use step1 post id to send request to get post page
// 3. find media id from the reponse text of step2
// 4. find app id in clicked page
// 5. send info api request with media id and app id
// 6. get media url from response json
try {
const appIdPattern = /"X-IG-App-ID":"([\d]+)"/;
const mediaIdPattern = /instagram:\/\/media\?id=(\d+)|["' ]media_id["' ]:["' ](\d+)["' ]/;
function findAppId() {
let bodyScripts = document.querySelectorAll("body > script");
for (let i = 0; i < bodyScripts.length; ++i) {
let match = bodyScripts[i].text.match(appIdPattern);
if (match) return match[1];
}
// Renaming that key used to silently disable the whole API path (and
// with it original-quality URLs). This constant has been Instagram's
// web App ID since ~2020 and is the hardcoded default in yt-dlp,
// gallery-dl, and instaloader
console.warn("IGDL: X-IG-App-ID not found on the page — using the known web App ID");
return WEB_APP_ID;
}
async function findMediaId() {
// method 1: extract from url.
function method1() {
let href = window.location.href;
let match = href.match(/www.instagram.com\/stories\/[^/]+\/(\d+)/);
if (!href.includes("highlights") && match) return match[1];
}
// method 3
async function method3() {
let postId = await findPostId(articleNode);
if (!postId) {
return null;
}
// Decode it locally first — this is what the page fetch below was
// scraping for, and it costs nothing
const decoded = shortcodeToMediaId(postId);
if (decoded) return decoded;
if (!mediaIdCache.has(postId)) {
let postUrl = `https://www.instagram.com/p/${postId}/`;
let resp = await fetch(postUrl);
let text = await resp.text();
let idMatch = text ? text.match(mediaIdPattern) : [];
let mediaId = null;
for (let i = 0; i < idMatch.length; ++i) {
if (idMatch[i]) mediaId = idMatch[i];
}
if (!mediaId) return null;
cacheBounded(mediaIdCache, postId, mediaId);
}
return mediaIdCache.get(postId);
}
function method2() {
let scriptJson = document.querySelectorAll('script[type="application/json"]');
for (let i = 0; i < scriptJson.length; i++) {
let match = scriptJson[i].text.match(/"pk":"(\d+)","id":"[\d_]+"/);
if (match) {
if (!window.location.href.includes("highlights")) {
return match[1];
}
let matchs = Array.from(scriptJson[i].text.matchAll(/"pk":"(\d+)","id":"[\d_]+"/g), match => match[1]);
const matchIndex = findHighlightsIndex();
if (matchs.length > matchIndex) {
return matchs[matchIndex];
}
}
}
}
return method1() || await method3() || method2();
}
function getImgOrVideoUrl(item) {
const byArea = (a, b) => (b.width * b.height > a.width * a.height ? b : a);
if (Array.isArray(item.video_versions) && item.video_versions.length) {
// Rank on dimensions — the version "type" field (101/102/103) is
// NOT a quality scale, and [0] isn't guaranteed to be the best
return item.video_versions.reduce(byArea).url;
}
const candidates = item.image_versions2?.candidates || [];
if (!candidates.length) return null;
// `candidates` concatenates two ladders: the native-aspect one and a
// 1:1 CENTRE-CROP one. Picking purely by size hands back the crop, so
// filter to the item's own aspect ratio first (per-slide on carousels)
const width = item.original_width;
const height = item.original_height;
let pool = candidates;
if (width && height) {
const ratio = width / height;
const sameShape = candidates.filter(
(c) => c.width && c.height && Math.abs(c.width / c.height - ratio) < 0.01,
);
if (sameShape.length) pool = sameShape;
}
return pool.reduce(byArea).url;
}
let appId = findAppId();
if (!appId) return null;
let headers = {
method: "GET",
headers: {
Accept: "*/*",
"X-IG-App-ID": appId
},
credentials: "include",
mode: "cors"
};
let mediaId = await findMediaId();
if (!mediaId) {
console.log("Cannot find media id");
return null;
}
if (!infoCache.has(mediaId)) {
// Host and App ID must MATCH: i.instagram.com expects the iOS App ID
// and an iOS user-agent, and 404s when paired with the web App ID we
// scrape from the page (yt-dlp #17275 / PR #17278, 2026-07). That 404
// was silent here — it downgraded every download to the DOM's
// display-resolution copy. www.instagram.com is the web pairing, and
// is same-origin for us besides
let url = "https://www.instagram.com/api/v1/media/" + mediaId + "/info/";
// Cross-origin (i. vs www.), so same sandbox/CORS caveat as the media
// download — this is the path that yields ORIGINAL-quality URLs, and
// failing it silently downgrades every download to the DOM's
// display-resolution copy
let respJson = await fetchInfoJson(url, appId, headers);
if (!respJson) return null;
cacheBounded(infoCache, mediaId, respJson);
}
let infoJson = infoCache.get(mediaId);
const item = infoJson.items[0];
if ("carousel_media" in item) {
// multi-media post
if (returnAll) return item.carousel_media.map(getImgOrVideoUrl).filter(Boolean);
return getImgOrVideoUrl(item.carousel_media[mediaIdx]);
} else {
// single media post
if (returnAll) {
const single = getImgOrVideoUrl(item);
return single ? [single] : [];
}
return getImgOrVideoUrl(item);
}
} catch (e) {
console.warn("IGDL: uncaught in getUrlFromInfoApi():", e);
return null;
}
}
function findPostName(articleNode) {
// this grabs the username link that is visually in the author's post comment below the media
// 'article section' includes the likes section and comment box
// '+ * a' pulls the first element after the section that contains a link (comment box doesn't)
// '[href^="/"][href$="/"]' requires the href attribute to begin and end with a slash to match a username
let imgNoCanvas = articleNode.querySelector('article section + * a[href^="/"][href$="/"]');
if (imgNoCanvas) {
return imgNoCanvas;
}
// videos are handled differently
let imgAlt = articleNode.querySelector("canvas ~ * img");
if (imgAlt) {
imgAlt = imgAlt.getAttribute("alt") ?? "";
let links = articleNode.querySelectorAll("a");
for (let i = 0; i < links.length; i++) {
// Anchors without an href exist (buttons, in-page targets) — indexing
// straight into .replace() threw before reaching the real match
const href = links[i].getAttribute("href");
if (!href) continue;
const posterName = href.replace(/\//g, "");
if (posterName && imgAlt.includes(posterName)) {
return links[i];
}
}
} else {
// first H2 with a direction set; absent on surfaces like the reel viewer,
// where the caller falls back to a placeholder name
const el = document.querySelector("h2[dir]");
return el ? el.innerText : null;
}
return null;
}
// Which DOM subtree the media for a given action bar lives in. Feed posts
// and the expanded modal are wrapped in <article>; reel rails are not
// wrapped in anything nameable, so climb until a container holds the video.
// Nothing found is NULL, never document.body: a body-wide scope satisfies
// every downstream check by accident — postGetArticleNode accepts it,
// isActiveControl always passes on a box that straddles the midpoint, and
// postGetUrl then scans the WHOLE PAGE for a <video>/<img>/carousel <li>
// and can resolve and download some unrelated post's media. Failing to
// resolve must stay a clean red flash
function resolveMediaScope(bar) {
if (!bar) return null;
const article = bar.closest("article");
if (article) return article;
let node = bar.parentElement;
for (let hop = 0; hop < 10 && node && node !== document.body; hop++) {
if (node.querySelector("video")) return node;
node = node.parentElement;
}
return bar.closest("main");
}
// Overlay surfaces are named by the URL: the reel on screen is the one in
// location.pathname. Only a real feed article inside <main> is trusted to
// name itself, so an overlay's URL can never relabel the posts behind it.
// The modal's container is article[role="presentation"] — a tag-name test
// would wrongly send it down the feed path
function prefersUrlShortcode(node) {
if (!node || !node.closest) return true;
if (node.closest("div[role=\"dialog\"]")) return true;
if (!node.closest("main")) return true;
const article = node.closest("article");
if (!article) return true;
return article.getAttribute("role") === "presentation";
}
// A post shortcode, or null when the path names one of Instagram's own routes
function shortcodeFrom(path) {
const match = path ? path.match(postIdPattern) : null;
if (!match || NON_SHORTCODE_SEGMENTS.has(match[1])) return null;
return match[1];
}
function findPostId(articleNode) {
// A feed article owns its permalink, so its own anchors win — a post modal
// open over the feed must not relabel the articles behind it. The reel
// viewer hands us MAIN instead, whose anchors belong to the audio page and
// to whichever neighbouring reels the scroller has rendered, so there the
// URL is the only trustworthy source
const scoped = !prefersUrlShortcode(articleNode);
const fromUrl = () => shortcodeFrom(location.pathname);
const fromDom = () => {
if (!articleNode || !articleNode.querySelectorAll) return null;
const aNodes = articleNode.querySelectorAll("a");
for (let i = 0; i < aNodes.length; ++i) {
const id = shortcodeFrom(aNodes[i].getAttribute("href"));
if (id) return id;
}
return null;
};
return scoped ? (fromDom() ?? fromUrl()) : (fromUrl() ?? fromDom());
}
// Legacy DOM-scraping fallback, used only when the info API can't resolve the
// media. It needs a poster frame and a permalink to scrape; the reel viewer
// supplies neither (no <time> anywhere, video has no poster), so every
// prerequisite bails to null — the caller reports a clean failure instead of
// throwing out of the hover prefetch
async function fetchVideoURL(articleNode, videoElem) {
let poster = videoElem.getAttribute("poster");
if (!poster) {
console.warn("IGDL: video has no poster frame — cannot scrape its URL");
return null;
}
const posterPattern = /\/([^/?]*)\?/;
let posterMatch = poster.match(posterPattern);
if (!posterMatch) {
console.warn("IGDL: unrecognised poster URL shape — cannot scrape the video URL");
return null;
}
// Prefer the resolved shortcode. The <time> grandparent below is a 2021-era
// trick that only holds on the classic feed layout, and indexing it blind
// threw whenever the surface had no timestamp
// special thanks 孙年忠 (https://greasyfork.org/en/scripts/406535-instagram-download-button/discussions/120159)
let timeNodes = articleNode.querySelectorAll("time");
let postId = findPostId(articleNode);
let posterUrl = postId
? `https://www.instagram.com/p/${postId}/`
: timeNodes[timeNodes.length - 1]?.parentNode?.parentNode?.href;
if (!posterUrl) {
console.warn("IGDL: could not resolve the post permalink to scrape");
return null;
}
let postFileName = posterMatch[1];
let resp = await fetch(posterUrl);
let content = await resp.text();
// special thanks to 孙年忠 for the pattern (https://greasyfork.org/zh-TW/scripts/406535-instagram-download-button/discussions/116675)
const pattern = new RegExp(`${postFileName}.*?video_versions.*?url":("[^"]*")`, "s");
let match = content.match(pattern);
if (!match) {
console.warn("IGDL: post page carries no video_versions for this media");
return null;
}
let videoUrl = JSON.parse(match[1]);
videoUrl = videoUrl.replace(/^(?:https?:\/\/)?(?:[^@/\n]+@)?(?:www\.)?([^:/?\n]+)/g, "https://scontent.cdninstagram.com");
videoElem.setAttribute("videoURL", videoUrl);
return videoUrl;
}
// ================================
// ==== Story & Highlight ====
// ================================
async function storyOnMouseIn(target) {
let sectionNode = storyGetSectionNode(target);
let url = await storyGetUrl(target, sectionNode);
target.setAttribute("href", url);
}
async function storyOnClicked(target) {
// extract url from target story and download or open it
let sectionNode = storyGetSectionNode(target);
let url = await storyGetUrl(target, sectionNode);
const posterUrlPat = /\/stories\/(.*)\/.*\//;
// download or open media url
if (target.getAttribute("class").includes("download-btn")) {
let mediaName = url.split("?")[0].split("\\").pop().split("/").pop();
mediaName = mediaName.substring(0, mediaName.lastIndexOf("."));
let datetime = new Date(sectionNode.querySelector("time").getAttribute("datetime"));
let posterName = "unknown";
// method 1
const posterNameHeader = sectionNode.querySelector("header a");
if (posterNameHeader) {
posterName = posterNameHeader.getAttribute("href").replace(/\//g, "");
}
// method 2
if (posterName === "unknown") {
const match = window.location.pathname.match(posterUrlPat);
if (match) {
posterName = match[1];
}
}
let filename = filenameFormat(storyFilenameTemplate, posterName, datetime, mediaName);
downloadResource(url, filename, target);
} else {
// open url in new tab
openResource(url);
}
}
function storyGetSectionNode(target) {
let sectionNode = target;
while (sectionNode && sectionNode.tagName !== "SECTION") {
sectionNode = sectionNode.parentNode;
}
return sectionNode;
}
async function storyGetUrl(target, sectionNode) {
let url = null;
if (!disableNewUrlFetchMethod) url = await getUrlFromInfoApi(target);
if (!url) {
if (sectionNode.querySelector("video > source")) {
url = sectionNode.querySelector("video > source").getAttribute("src");
} else if (sectionNode.querySelector('img[decoding="sync"]')) {
let img = sectionNode.querySelector('img[decoding="sync"]');
url = img.srcset.split(/ \d+w/g)[0].trim(); // extract first src from srcset attr. of img
if (url.length > 0) {
return url;
}
url = sectionNode.querySelector('img[decoding="sync"]').getAttribute("src");
} else if (sectionNode.querySelector("video")) {
url = sectionNode.querySelector("video").getAttribute("src");
}
}
return url;
}
// Values come from page text (display names, alt text, CDN paths) and go
// straight into a[download] — they must not carry path separators, chars
// Windows forbids, control characters, or a reserved device name
const RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i;
// Built from escapes, not literals, so no raw control bytes land in this file
// eslint-disable-next-line no-control-regex -- matching them is the point
const ILLEGAL_FILENAME_CHARS = new RegExp("[\\\\/:*?\"<>|\\u0000-\\u001F]", "g");
function sanitizeFilename(name) {
let safe = String(name ?? "")
.replace(ILLEGAL_FILENAME_CHARS, "_")
.replace(/\s+/g, " ")
.trim()
.replace(/^\.+/, "") // no leading dots (hidden files / traversal)
.replace(/[. ]+$/, ""); // Windows strips trailing dots and spaces
if (RESERVED_NAMES.test(safe)) safe = "_" + safe;
// Filesystems cap on BYTES, not characters — non-Latin names overflow far
// sooner than the character count suggests
const encoder = new TextEncoder();
while (encoder.encode(safe).length > 110) safe = safe.slice(0, -1);
return safe || "instagram";
}
function filenameFormat(template, id, datetime, medianame, postId = +new Date(), mediaIndex = "0") {
let filename = template;
filename = filename.replace(/%id%/g, id);
filename = filename.replace(/%datetime%/g, datetimeFormat(datetimeTemplate, datetime));
filename = filename.replace(/%medianame%/g, medianame);
filename = filename.replace(/%postId%/g, postId);
filename = filename.replace(/%mediaIndex%/g, mediaIndex);
return sanitizeFilename(filename);
}
function datetimeFormat(template, datetime) {
let datetimeStr = template;
datetimeStr = datetimeStr.replace(/%y%/g, datetime.getFullYear());
datetimeStr = datetimeStr.replace(/%m%/g, fillZero((datetime.getMonth() + 1).toString()));
datetimeStr = datetimeStr.replace(/%d%/g, fillZero(datetime.getDate().toString()));
datetimeStr = datetimeStr.replace(/%H%/g, fillZero(datetime.getHours().toString()));
datetimeStr = datetimeStr.replace(/%M%/g, fillZero(datetime.getMinutes().toString()));
datetimeStr = datetimeStr.replace(/%S%/g, fillZero(datetime.getSeconds().toString()));
return datetimeStr;
}
function fillZero(str) {
if (str.length === 1) {
return "0" + str;
}
return str;
}
function openResource(url) {
// open url in new tab
var a = document.createElement("a");
a.href = url;
a.setAttribute("target", "_blank");
document.body.appendChild(a);
a.click();
a.remove();
}
// ownsUrl: true only when WE created the object URL. A video's blob: URL
// belongs to the page's MediaSource — revoking that would break Instagram's
// own player for that clip
function forceDownload(blob, filename, extension, ownsUrl = false) {
// ref: https://stackoverflow.com/questions/49474775/chrome-65-blocks-cross-origin-a-download-client-side-workaround-to-force-down
var a = document.createElement("a");
if (replaceJpegWithJpg) extension = extension.replace("jpeg", "jpg");
// Sanitize HERE: this is the only place a download name is set, and the
// profile path and the URL-derived fallback both skip filenameFormat
a.download = sanitizeFilename(filename) + "." + extension;
a.href = blob;
a.style.display = "none";
// Instagram's delegated handlers must not intercept this click
a.addEventListener("click", (e) => e.stopPropagation());
// For Firefox https://stackoverflow.com/a/32226068
document.body.appendChild(a);
a.click();
a.remove();
// Object URLs are held for the page's lifetime until revoked — a leaked
// blob per download. Delayed, not immediate: revoking straight after
// click() can kill the download (Firefox bug 1282407)
if (ownsUrl) {
setTimeout(() => URL.revokeObjectURL(blob), 10000);
}
}
// The info API is on i.instagram.com while the page is www.instagram.com.
// GM_xmlhttpRequest is CORS-exempt and sends the instagram.com cookies the
// endpoint requires; plain fetch stays as the no-grant fallback
function fetchInfoJson(url, appId, fetchOptions) {
if (typeof GM_xmlhttpRequest === "function") {
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: "GET",
url,
headers: { Accept: "*/*", "X-IG-App-ID": appId },
responseType: "json",
timeout: 20000,
onload: (r) => {
if (r.status !== 200) {
console.warn(`IGDL: info API returned ${r.status}`);
resolve(null);
return;
}
try {
resolve(r.response ?? JSON.parse(r.responseText));
} catch {
resolve(null);
}
},
onerror: () => resolve(null),
onabort: () => resolve(null),
ontimeout: () => resolve(null),
});
});
}
return fetch(url, fetchOptions)
.then((resp) => {
if (resp.status !== 200) {
console.warn(`IGDL: info API returned ${resp.status}`);
return null;
}
return resp.json();
})
.catch(() => null);
}
// Media lives on the CDN — a different origin. A plain fetch is subject to
// CORS and to whichever context the userscript manager runs us in (adding
// GM grants moves the script into the sandbox, where cross-origin fetch is
// handled differently), so prefer GM_xmlhttpRequest, which is exempt. Same
// approach the sibling Twitter/Bluesky scripts use for media
function fetchMediaBlob(url) {
if (typeof GM_xmlhttpRequest === "function") {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url,
responseType: "blob",
timeout: 60000,
onload: (r) => {
if (r.status < 200 || r.status >= 300) {
reject(new Error(`HTTP ${r.status}`));
return;
}
// A null response here means the transfer failed to allocate; it
// does not throw, so without this the save writes a bogus file
// and still flashes success
if (!r.response) {
reject(new Error("Empty response (file too large for memory?)"));
return;
}
resolve(r.response);
},
onerror: () => reject(new Error("Network error")),
onabort: () => reject(new Error("Aborted")),
ontimeout: () => reject(new Error("Timeout")),
});
});
}
// Page-context fallback (no grants): the browser's own CORS rules apply.
// The old explicit User-Agent/Origin headers are gone — both are
// forbidden header names that browsers drop anyway
return fetch(url, { mode: "cors" }).then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.blob();
});
}
// btn (optional) gets loading → success/failed feedback states.
// onDone (optional) is called with true/false when the save settles, so a
// batch can pace itself instead of firing everything at once
function downloadResource(url, filename, btn, onDone) {
if (url.startsWith("blob:")) {
forceDownload(url, filename, "mp4");
flashState(btn, "igdl-success");
onDone?.(true);
return;
}
// ref: https://stackoverflow.com/questions/49474775/chrome-65-blocks-cross-origin-a-download-client-side-workaround-to-force-down
if (!filename) {
filename = url
.split("\\")
.pop()
.split("/")
.pop();
}
flashState(btn, "igdl-loading");
fetchMediaBlob(url)
.then(blob => {
// GM_xmlhttpRequest blobs can arrive without a MIME type — fall back
// to the extension in the CDN path
const extension = (blob.type && blob.type.split("/").pop())
|| url.split("?")[0].split(".").pop()
|| "jpg";
let blobUrl = window.URL.createObjectURL(blob);
forceDownload(blobUrl, filename, extension, true);
flashState(btn, "igdl-success");
onDone?.(true);
})
.catch(e => {
console.error("IGDL: download failed", e);
flashState(btn, "igdl-failed");
onDone?.(false);
});
}
})();