Instagram Download Button

Download photos, videos, reels, stories, and profile pictures from Instagram with one click

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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);
      });
  }
})();