Instagram Download Button

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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