insta-helper

Adds download and open-in-new-tab buttons to Instagram posts and stories.

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 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         insta-helper
// @namespace    insta-helper
// @version      3.5.1
// @compatible   chrome
// @description  Adds download and open-in-new-tab buttons to Instagram posts and stories.
// @author       insta-helper
// @match        https://www.instagram.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=instagram.com
// @grant        GM_xmlhttpRequest
// @connect      cdninstagram.com
// @connect      scontent.cdninstagram.com
// @connect      instagram.com
// @connect      i.instagram.com
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    // ─── Config ───────────────────────────────────────────────────────────────

    // Filename template for saved files.
    // Placeholders: %username% %date% %time% %filename%
    const FILENAME_TEMPLATE = '%username%-%date%_%time%-%filename%';

    // How many ms between each DOM scan
    const SCAN_INTERVAL = 600;

    // How many scans to wait on a story page before giving up on finding inline controls
    // and falling back to a floating overlay button (6 * 600ms = ~3.6s)
    const STORY_FALLBACK_AFTER = 6;

    // ─── SVG icons ────────────────────────────────────────────────────────────

    const ICON_DOWNLOAD = (color) => `<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
    const ICON_OPEN     = (color) => `<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>`;

    // ─── State ────────────────────────────────────────────────────────────────

    let previousUrl = '';
    let storyRetryCount = 0;
    let infoApiCache = {};   // mediaId → API response JSON
    let mediaIdCache = {};   // postShortcode → mediaId
    let capturedStoryVideoUrls = []; // CDN video URLs captured via fetch/XHR intercept

    // ─── MSE network intercept ────────────────────────────────────────────────
    // Instagram loads story videos via Media Source Extensions (MSE). The video
    // element src is a blob:// URL, not a CDN URL. We intercept fetch() and XHR
    // to capture the actual CDN segment URLs before they are piped into the MSE
    // buffer. These captured URLs can be opened/downloaded directly.

    (function installNetworkInterceptor() {
        const CDN_RE = /https:\/\/[^"'\s]+(?:cdninstagram\.com|fbcdn\.net)[^"'\s]+\.mp4[^"'\s]*/;

        const _fetch = window.fetch;
        window.fetch = function (input, init) {
            const url = typeof input === 'string' ? input : (input && input.url) || '';
            if (CDN_RE.test(url) && !capturedStoryVideoUrls.includes(url)) {
                capturedStoryVideoUrls.push(url);
                console.log('[insta-helper] captured CDN video via fetch:', url.substring(0, 100));
            }
            return _fetch.apply(this, arguments);
        };

        const _open = XMLHttpRequest.prototype.open;
        XMLHttpRequest.prototype.open = function (method, url) {
            if (typeof url === 'string' && CDN_RE.test(url) && !capturedStoryVideoUrls.includes(url)) {
                capturedStoryVideoUrls.push(url);
                console.log('[insta-helper] captured CDN video via XHR:', url.substring(0, 100));
            }
            return _open.apply(this, arguments);
        };
    })();

    // ─── Utility ──────────────────────────────────────────────────────────────

    function getIconColor() {
        const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g) || [0, 0, 0];
        const brightness = rgb[0] * 0.299 + rgb[1] * 0.587 + rgb[2] * 0.114;
        return brightness <= 150 ? '#ffffff' : '#000000';
    }

    function pad(n) {
        return String(n).padStart(2, '0');
    }

    function formatDate(d) {
        return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
    }

    function formatTime(d) {
        return `${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
    }

    function buildFilename(username, date, rawUrl) {
        const baseName = rawUrl.split('?')[0].split('/').pop().replace(/\.[^.]+$/, '');
        return FILENAME_TEMPLATE
            .replace('%username%', username || 'unknown')
            .replace('%date%', formatDate(date))
            .replace('%time%', formatTime(date))
            .replace('%filename%', baseName || 'media');
    }

    function guessExtension(url, blobType) {
        if (blobType && blobType !== 'application/octet-stream') {
            return blobType.split('/').pop().replace('jpeg', 'jpg');
        }
        const ext = url.split('?')[0].split('.').pop().toLowerCase();
        return ['mp4', 'jpg', 'jpeg', 'png', 'webp'].includes(ext) ? ext : 'jpg';
    }

    function openInNewTab(url) {
        const a = document.createElement('a');
        a.href = url;
        a.target = '_blank';
        a.rel = 'noopener noreferrer';
        document.body.appendChild(a);
        a.click();
        a.remove();
    }

    function triggerDownload(blobUrl, filename, ext) {
        const a = document.createElement('a');
        a.href = blobUrl;
        a.download = `${filename}.${ext}`;
        document.body.appendChild(a);
        a.click();
        a.remove();
    }

    // Fetch binary data via GM_xmlhttpRequest (bypasses CORS), falling back to
    // a plain XHR with credentials, then finally a direct <a download> link.
    function fetchBinaryGM(url) {
        return new Promise((resolve, reject) => {
            if (typeof GM_xmlhttpRequest === 'function') {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url,
                    responseType: 'blob',
                    anonymous: false,
                    onload: (r) => resolve(r.response),
                    onerror: reject,
                    ontimeout: reject,
                });
            } else {
                // Fallback: plain XHR — works when the CDN allows credentialed requests
                const xhr = new XMLHttpRequest();
                xhr.open('GET', url, true);
                xhr.responseType = 'blob';
                xhr.withCredentials = true;
                xhr.onload = () => (xhr.status >= 200 && xhr.status < 300) ? resolve(xhr.response) : reject(new Error(`HTTP ${xhr.status}`));
                xhr.onerror = reject;
                xhr.send();
            }
        });
    }

    async function downloadUrl(url, filename) {
        if (!url) { console.warn('[insta-helper] downloadUrl: no url'); return; }

        // Blob URLs from MSE players can be saved directly
        if (url.startsWith('blob:')) {
            triggerDownload(url, filename, 'mp4');
            return;
        }

        try {
            const blob = await fetchBinaryGM(url);
            const ext = guessExtension(url, blob.type);
            const blobUrl = URL.createObjectURL(blob);
            triggerDownload(blobUrl, filename, ext);
            setTimeout(() => URL.revokeObjectURL(blobUrl), 60000);
        } catch (err) {
            console.warn('[insta-helper] binary fetch failed, using direct download link:', err);
            // Last resort: <a download> — browser will prompt a save dialog for same-origin
            // resources; for cross-origin it will open in a new tab but at least it's the
            // correct URL rather than silently doing nothing.
            const ext = guessExtension(url, '');
            triggerDownload(url, filename, ext);
        }
    }

    // ─── Instagram internal API helpers ───────────────────────────────────────

    function findAppId() {
        const patterns = [
            /"X-IG-App-ID":"(\d+)"/,
            /X-IG-App-ID['":\s]+(\d{10,})/,
            /instagramWebDesktopFBAppId['":\s]+['"(]?(\d{10,})/,
            /appId['":\s]+(\d{15,})/,
        ];
        for (const script of document.querySelectorAll('body > script, script[type="application/json"]')) {
            const text = script.textContent || '';
            if (!text) continue;
            for (const pat of patterns) {
                const m = text.match(pat);
                if (m) return m[1];
            }
        }
        // Last resort: search all scripts including those in head
        for (const script of document.querySelectorAll('script')) {
            const text = script.textContent || '';
            for (const pat of patterns) {
                const m = text.match(pat);
                if (m) return m[1];
            }
        }
        return null;
    }

    function fetchMediaInfoGM(mediaId, appId) {
        return new Promise((resolve) => {
            const url = `https://i.instagram.com/api/v1/media/${mediaId}/info/`;
            if (typeof GM_xmlhttpRequest === 'function') {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url,
                    headers: { 'X-IG-App-ID': appId, 'Accept': '*/*' },
                    withCredentials: true,
                    responseType: 'json',
                    onload: (r) => {
                        if (r.status >= 200 && r.status < 300) {
                            resolve(r.response);
                        } else {
                            console.warn('[insta-helper] API HTTP error:', r.status, r.statusText);
                            resolve(null);
                        }
                    },
                    onerror: (e) => { console.warn('[insta-helper] API request error:', e); resolve(null); },
                    ontimeout: () => { console.warn('[insta-helper] API request timed out'); resolve(null); },
                });
            } else {
                // GM not available — fall back to fetch (may be blocked by CORS)
                fetch(url, {
                    method: 'GET',
                    headers: { 'X-IG-App-ID': appId, Accept: '*/*' },
                    credentials: 'include',
                })
                .then(r => r.ok ? r.json() : (console.warn('[insta-helper] fetch API HTTP error:', r.status), null))
                .then(resolve)
                .catch(err => { console.warn('[insta-helper] fetch API error:', err); resolve(null); });
            }
        });
    }

    async function fetchMediaInfo(mediaId, appId) {
        if (infoApiCache[mediaId]) return infoApiCache[mediaId];
        const json = await fetchMediaInfoGM(mediaId, appId);
        if (json) infoApiCache[mediaId] = json;
        return json;
    }

    function extractBestUrlFromItem(item) {
        if (item.video_versions?.length) return item.video_versions[0].url;
        return item.image_versions2?.candidates?.[0]?.url ?? null;
    }

    // Resolve the numeric media ID for a post shortcode by fetching the post page
    async function resolvePostMediaId(shortcode) {
        if (mediaIdCache[shortcode]) return mediaIdCache[shortcode];
        try {
            const resp = await fetch(`https://www.instagram.com/p/${shortcode}/`);
            const text = await resp.text();
            const patterns = [
                /instagram:\/\/media\?id=(\d+)/,
                /"media_id":"(\d+)"/,
                /"pk":"(\d+)","id":"[\d_]+"/,
            ];
            for (const pat of patterns) {
                const m = text.match(pat);
                if (m) { mediaIdCache[shortcode] = m[1]; return m[1]; }
            }
        } catch { /* ignore */ }
        return null;
    }

    // Attempt to get the highest-quality URL via the internal API.
    // Returns null on any failure — callers must have a DOM fallback.
    async function getHighQualityUrl(shortcode, mediaIndex) {
        try {
            const appId = findAppId();
            if (!appId) { console.warn('[insta-helper] getHighQualityUrl: appId not found'); return null; }

            const storyIdMatch = window.location.pathname.match(/\/stories\/[^/]+\/(\d+)/);
            const mediaId = storyIdMatch ? storyIdMatch[1] : await resolvePostMediaId(shortcode);
            if (!mediaId) { console.warn('[insta-helper] getHighQualityUrl: mediaId not resolved'); return null; }

            console.log('[insta-helper] getHighQualityUrl: fetching mediaId', mediaId);
            const info = await fetchMediaInfo(mediaId, appId);
            if (!info) { console.warn('[insta-helper] getHighQualityUrl: API returned null'); return null; }

            const item = info?.items?.[0];
            if (!item) { console.warn('[insta-helper] getHighQualityUrl: no items in response', info); return null; }

            const url = item.carousel_media
                ? extractBestUrlFromItem(item.carousel_media[mediaIndex ?? 0])
                : extractBestUrlFromItem(item);
            if (!url) { console.warn('[insta-helper] getHighQualityUrl: could not extract URL from item', item); }
            return url;
        } catch (err) {
            console.warn('[insta-helper] API lookup failed:', err);
            return null;
        }
    }

    // ─── Post media URL resolution ────────────────────────────────────────────

    // Find the post shortcode from <a> links inside the article
    function getPostShortcode(articleNode) {
        for (const a of articleNode.querySelectorAll('a[href]')) {
            const m = a.getAttribute('href').match(/\/p\/([\w-]+)\//);
            if (m) return m[1];
        }
        return null;
    }

    // Extract the poster's username from the article header
    function getPostUsername(articleNode) {
        // 1. Anchor inside the article header (classic layout)
        const headerA = articleNode.querySelector('header a[href]');
        if (headerA) return headerA.getAttribute('href').replace(/\//g, '');

        // 2. Any anchor whose href looks like /username/ inside the article
        for (const a of articleNode.querySelectorAll('a[href]')) {
            const href = a.getAttribute('href') || '';
            if (/^\/[^/]+\/$/.test(href) && !href.includes('/explore/') && !href.includes('/p/') && !href.includes('/reel/')) {
                return href.replace(/\//g, '');
            }
        }

        // 3. h2 with a dir attribute (feed dialog)
        const h2 = articleNode.querySelector('h2[dir]') || document.querySelector('h2[dir]');
        if (h2) return h2.innerText.trim();

        // 4. span that holds the username in newer layouts (bold/semibold text near top of article)
        const spans = articleNode.querySelectorAll('span[class]');
        for (const span of spans) {
            const txt = span.innerText.trim();
            if (txt && /^[\w.]{1,30}$/.test(txt) && !txt.includes(' ')) return txt;
        }

        // 5. URL path fallback for post permalink pages (/p/shortcode/ or /reel/shortcode/)
        const pathMatch = window.location.pathname.match(/\/(p|reel)\/([^/]+)/);
        if (pathMatch) {
            // Try to find username in the page heading
            const heading = document.querySelector('h1, h2, h3');
            if (heading) {
                const txt = heading.innerText.trim();
                if (txt && /^[\w.]+$/.test(txt)) return txt;
            }
        }

        return 'unknown';
    }

    // Get which carousel slide is currently visible (0-based index)
    function getCarouselIndex(articleNode) {
        const dots = [...articleNode.querySelectorAll('div._acnb')];
        if (!dots.length) return 0;
        const active = dots.findIndex(d => d.classList.length === 2);
        return active >= 0 ? active : 0;
    }

    // When a video src is a blob, try to resolve the real CDN URL from the post page HTML
    async function resolveVideoSrc(articleNode, videoEl) {
        if (videoEl.dataset.resolvedSrc) return videoEl.dataset.resolvedSrc;

        const poster = videoEl.getAttribute('poster') || '';
        const posterFilename = poster.split('?')[0].split('/').pop();
        const timeNodes = articleNode.querySelectorAll('time');
        const postLink = timeNodes[timeNodes.length - 1]?.parentElement?.closest('a')?.href;
        if (!postLink || !posterFilename) return null;

        try {
            const resp = await fetch(postLink);
            const html = await resp.text();
            const pat = new RegExp(`${posterFilename}.*?video_versions.*?"url":"([^"]+)"`, 's');
            const m = html.match(pat);
            if (!m) return null;
            let url = JSON.parse(`"${m[1].replace(/\\/g, '\\\\')}"`);
            url = url.replace(/^https?:\/\/[^/]+/, 'https://scontent.cdninstagram.com');
            videoEl.dataset.resolvedSrc = url;
            return url;
        } catch {
            return null;
        }
    }

    // DOM-based media URL scraping (used when the API route fails)
    async function getPostUrlFromDom(articleNode, mediaIndex) {
        const isCarousel = articleNode.querySelectorAll('li[style][class]').length > 0;

        if (!isCarousel) {
            // Single post: check for video first, then image
            const video = articleNode.querySelector('video');
            if (video) {
                const src = video.src || video.getAttribute('src') || '';
                if (src && !src.startsWith('blob:')) return src;
                // Try to resolve the real URL from the post page
                const resolved = await resolveVideoSrc(articleNode, video);
                if (resolved) return resolved;
                // Last resort: return the blob URL as-is (browser can still download it)
                return src || null;
            }
            // Try several image selectors in order of reliability
            const imgSelectors = [
                'div[role] div > img[src*="instagram"]',
                'img[style*="object-fit"][src*="instagram"]',
                'article img[src*="cdninstagram"]',
                'img[src*="cdninstagram"]',
            ];
            for (const sel of imgSelectors) {
                const img = articleNode.querySelector(sel);
                if (img?.src) return img.src;
            }
            return null;
        }

        // Carousel post: find the <li> at the currently visible index
        const isPostPage = location.pathname.startsWith('/p/');
        const listItems = [...articleNode.querySelectorAll(
            `:scope > div > div:nth-child(${isPostPage ? 1 : 2}) > div > div:nth-child(1) ul li[style*="translateX"]`
        )];

        if (!listItems.length) {
            // Fallback: just grab any visible image in the article
            const img = articleNode.querySelector('img[src*="cdninstagram"]');
            return img?.src ?? null;
        }

        const itemWidth = Math.max(...listItems.map(li => li.clientWidth));
        const posMap = {};
        for (const li of listItems) {
            const m = li.style.transform.match(/-?(\d+)/);
            if (!m) continue;
            posMap[Math.round(Number(m[1]) / itemWidth)] = li;
        }

        const targetLi = posMap[mediaIndex] ?? posMap[0];
        if (!targetLi) return null;

        const video = targetLi.querySelector('video');
        if (video) {
            const src = video.src || video.getAttribute('src') || '';
            if (src && !src.startsWith('blob:')) return src;
            return await resolveVideoSrc(articleNode, video) || src || null;
        }
        return targetLi.querySelector('img')?.src ?? null;
    }

    // Main post URL resolver: API first, DOM fallback
    async function resolvePostUrl(articleNode) {
        const shortcode = getPostShortcode(articleNode);
        const mediaIndex = getCarouselIndex(articleNode);
        const username = getPostUsername(articleNode);

        console.log(`[insta-helper] resolving post: shortcode=${shortcode} index=${mediaIndex} user=${username}`);

        if (shortcode) {
            const apiUrl = await getHighQualityUrl(shortcode, mediaIndex);
            if (apiUrl) {
                console.log('[insta-helper] resolved via API:', apiUrl.substring(0, 80));
                return { url: apiUrl, mediaIndex, username };
            }
        }

        const url = await getPostUrlFromDom(articleNode, mediaIndex);
        console.log('[insta-helper] resolved via DOM:', url?.substring(0, 80));
        return { url, mediaIndex, username };
    }

    // ─── Story media URL resolution ───────────────────────────────────────────

    // Extract the username for the current story from the URL path
    function getStoryUsername() {
        const m = window.location.pathname.match(/\/stories\/([^/]+)/);
        if (m) return m[1];
        const a = document.querySelector('section header a[href], div[role="dialog"] header a[href]');
        if (a) return a.getAttribute('href').replace(/\//g, '');
        return 'unknown';
    }

    // Get the upload timestamp for the current story
    function getStoryDate(root) {
        const t = (root || document).querySelector('time[datetime]');
        return t ? new Date(t.getAttribute('datetime')) : new Date();
    }

    // Pick the highest-resolution URL from an HTML srcset string.
    // srcset format: "url1 400w, url2 800w, url3 1200w"
    function bestSrcsetUrl(srcset) {
        if (!srcset) return null;
        let bestUrl = null;
        let bestW = -1;
        for (const part of srcset.split(',')) {
            const tokens = part.trim().split(/\s+/);
            if (!tokens[0]) continue;
            const w = tokens[1] ? parseInt(tokens[1], 10) : 0;
            if (w > bestW) { bestW = w; bestUrl = tokens[0]; }
        }
        return bestUrl;
    }

    // DOM-based story URL scraping. Always queries document fresh — never uses a
    // stale captured reference — so it works after story navigation too.
    function getStoryUrlFromDom() {
        const allVideos = Array.from(document.querySelectorAll('video'));

        // 1. Currently playing video — unambiguously the active story
        for (const video of allVideos) {
            if (!video.paused) {
                const src = video.querySelector('source')?.src || video.src || video.getAttribute('src') || '';
                if (src && !src.startsWith('blob:') && src.startsWith('http')) return src;
            }
        }

        // 2. Visible video (on-screen rect, covers user-paused stories)
        for (const video of allVideos) {
            const rect = video.getBoundingClientRect();
            if (rect.width > 0 && rect.height > 0) {
                const src = video.querySelector('source')?.src || video.src || video.getAttribute('src') || '';
                if (src && !src.startsWith('blob:') && src.startsWith('http')) return src;
            }
        }

        // 3. Blob/MSE fallback — playing or visible video only
        for (const video of allVideos) {
            const rect = video.getBoundingClientRect();
            const active = !video.paused || (rect.width > 0 && rect.height > 0);
            if (active) {
                const src = video.querySelector('source')?.src || video.src || '';
                if (src.startsWith('blob:')) return src;
            }
        }

        // 4. Story image: Instagram marks the active story image with decoding="sync"
        const syncImg = document.querySelector('img[decoding="sync"]');
        if (syncImg) {
            const best = bestSrcsetUrl(syncImg.srcset);
            if (best) return best;
            if (syncImg.src && syncImg.src.includes('instagram')) return syncImg.src;
        }

        // 5. Visible CDN image (large enough to be story media, not a profile picture)
        for (const img of document.querySelectorAll('img[src*="cdninstagram"]')) {
            const rect = img.getBoundingClientRect();
            if (rect.width > 300 && rect.height > 300) return img.src;
        }

        return null;
    }

    function wait(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    // Main story URL resolver.
    // Strategy:
    //   1. Try API when story ID is in the URL path (most reliable, avoids blob issues)
    //   2. Check intercepted CDN fetch/XHR URLs captured by the network interceptor
    //   3. Poll DOM for a real HTTP CDN URL (non-blob)
    //   4. Last resort: DOM blob URL (MSE stream — open-in-tab won't work but download may)
    async function resolveStoryUrl() {
        const path = window.location.pathname;
        console.log('[insta-helper] resolving story... path:', path);

        // Step 1: API — only when the story numeric ID is confirmed in the path
        const storyIdInPath = path.match(/\/stories\/[^/]+\/(\d+)/);
        console.log('[insta-helper] storyIdInPath:', storyIdInPath ? storyIdInPath[1] : 'none — skipping API');
        if (storyIdInPath) {
            const appId = findAppId();
            console.log('[insta-helper] appId:', appId || 'NOT FOUND');
            const apiUrl = await getHighQualityUrl(null, 0);
            if (apiUrl) {
                console.log('[insta-helper] story via API:', apiUrl.substring(0, 80));
                return apiUrl;
            }
            console.warn('[insta-helper] API failed for story, falling back to DOM');
        }

        // Step 2: Use intercepted CDN network URLs (MSE fetch/XHR capture)
        // Poll briefly to give the interceptor a chance to catch the request
        for (let i = 0; i < 6; i++) {
            if (capturedStoryVideoUrls.length > 0) {
                const captured = capturedStoryVideoUrls[capturedStoryVideoUrls.length - 1];
                console.log('[insta-helper] story via captured network URL:', captured.substring(0, 100));
                return captured;
            }
            await wait(i === 0 ? 0 : 150);
        }
        console.log('[insta-helper] no captured CDN URLs yet, falling back to DOM poll');

        // Step 3: Poll DOM for a real HTTP URL (skip blob URLs in this pass)
        const delays = [0, 50, 150, 300, 500, 500, 500];
        let blobFallback = null;
        for (let i = 0; i < delays.length; i++) {
            if (delays[i] > 0) await wait(delays[i]);
            // Check interceptor again in case it captured something during DOM polling
            if (capturedStoryVideoUrls.length > 0) {
                const captured = capturedStoryVideoUrls[capturedStoryVideoUrls.length - 1];
                console.log('[insta-helper] story via captured network URL (late):', captured.substring(0, 100));
                return captured;
            }
            const candidate = getStoryUrlFromDom();
            if (candidate && !candidate.startsWith('blob:')) {
                console.log(`[insta-helper] story via DOM http (attempt ${i + 1}):`, candidate.substring(0, 80));
                return candidate;
            }
            if (candidate && candidate.startsWith('blob:') && !blobFallback) {
                blobFallback = candidate;
            }
        }

        // Step 4: blob URL — MSE stream reference. Download may work, open-in-tab will not.
        if (blobFallback) {
            console.warn('[insta-helper] story: only blob URL available — download only:', blobFallback);
            return blobFallback;
        }

        console.warn('[insta-helper] story: could not resolve media URL');
        return null;
    }

    // ─── Button creation ──────────────────────────────────────────────────────

    const BTN_STYLE = [
        'display:inline-flex',
        'align-items:center',
        'justify-content:center',
        'cursor:pointer',
        'background:none',
        'border:none',
        'padding:4px',
        'margin:0 2px',
        'opacity:0.85',
        'transition:opacity 0.15s',
        'z-index:9999',
        'vertical-align:middle',
        'flex-shrink:0',
    ].join(';');

    // Create a single button. The context object is stored in the closure so we
    // never need to do DOM traversal at click time — this is the key reliability fix.
    function createButton(type, color, context) {
        const btn = document.createElement('button');
        btn.type = 'button';
        btn.innerHTML = type === 'download' ? ICON_DOWNLOAD(color) : ICON_OPEN(color);
        btn.title = type === 'download' ? 'Download' : 'Open in new tab';
        btn.dataset.igHelper = type;
        btn.setAttribute('style', BTN_STYLE);

        btn.addEventListener('mouseenter', () => { btn.style.opacity = '1'; });
        btn.addEventListener('mouseleave', () => { btn.style.opacity = '0.85'; });

        // Block Instagram's hover listeners on parent elements using capture phase
        // Note: we do NOT block 'click' in capture — that would prevent our own handler
        for (const evt of ['mouseenter', 'mouseover', 'mousedown', 'mouseup']) {
            btn.addEventListener(evt, (e) => e.stopPropagation(), true);
        }

        // Click handler with context passed via closure — no DOM traversal needed
        btn.addEventListener('click', async (e) => {
            e.stopPropagation();
            e.preventDefault();
            btn.style.opacity = '0.4';
            try {
                await handleAction(type, context);
            } catch (err) {
                console.error('[insta-helper] click handler error:', err);
            } finally {
                btn.style.opacity = '0.85';
            }
        });

        return btn;
    }

    function createButtonPair(color, context) {
        return [createButton('open', color, context), createButton('download', color, context)];
    }

    // ─── Action handlers ──────────────────────────────────────────────────────

    // Unified action handler — context tells us whether this is a post or story
    async function handleAction(type, context) {
        if (context.isStory) {
            await handleStoryAction(type);
        } else {
            await handlePostAction(type, context.articleNode);
        }
    }

    async function handlePostAction(type, articleNode) {
        const { url, username } = await resolvePostUrl(articleNode);
        if (!url) { console.warn('[insta-helper] post: could not resolve media URL'); return; }

        if (type === 'download') {
            const timeEl = articleNode.querySelector('time[datetime]');
            const date = timeEl ? new Date(timeEl.getAttribute('datetime')) : new Date();
            const filename = buildFilename(username, date, url);
            await downloadUrl(url, filename);
        } else {
            openInNewTab(url);
        }
    }

    async function handleStoryAction(type) {
        const url = await resolveStoryUrl();
        if (!url) { console.warn('[insta-helper] story: could not resolve media URL'); return; }

        const isBlob = url.startsWith('blob:');

        if (type === 'download') {
            const username = getStoryUsername();
            const date = getStoryDate(null);
            const filename = buildFilename(username, date, isBlob ? 'story' : url);
            await downloadUrl(url, filename);
        } else {
            if (isBlob) {
                console.warn('[insta-helper] story: blob URL cannot be opened in new tab — use download instead');
                alert('This story uses a streaming format that cannot be opened in a new tab.\nUse the download button instead.');
                return;
            }
            openInNewTab(url);
        }
    }

    // ─── DOM injection: Posts ─────────────────────────────────────────────────

    // SVG path data that identifies the Instagram bookmark/save icon
    const SAVE_ICON_PATHS = [
        'M20 22a.999.999 0 0 1-.687-.273L12 14.815l-7.313 6.912A1 1 0 0 1 3 21V3a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1Z',
        '20 21 12 13.44 4 21 4 3 20 3 20 21',
    ];

    // Find the action bar row inside a post article (the row with like, comment, share, save buttons)
    function findPostActionBar(articleEl) {
        for (const svg of articleEl.querySelectorAll('svg')) {
            const hasSave = SAVE_ICON_PATHS.some(p =>
                svg.querySelector(`path[d="${p}"], polygon[points="${p}"]`)
            );
            if (!hasSave) continue;

            // Walk up to the role=button wrapper
            let node = svg;
            while (node && node !== articleEl) {
                if (node.getAttribute('role') === 'button' || node.tagName === 'BUTTON') break;
                node = node.parentNode;
            }
            if (!node || node === articleEl) continue;

            // The parent of the save-button wrapper is the action bar row
            return node.parentNode ?? null;
        }
        return null;
    }

    function articleHasButtons(articleEl) {
        return articleEl.querySelector('button[data-ig-helper]') !== null;
    }

    function injectPostButtons(articleEl, color) {
        const actionBar = findPostActionBar(articleEl);
        if (!actionBar) {
            console.log('[insta-helper] post: action bar not found');
            return;
        }

        // Pass the article node in the context closure so click handlers never need traversal
        const context = { isStory: false, articleNode: articleEl };
        const [openBtn, dlBtn] = createButtonPair(color, context);
        actionBar.appendChild(openBtn);
        actionBar.appendChild(dlBtn);
    }

    // ─── DOM injection: Stories ───────────────────────────────────────────────

    // SVG paths that identify the play/pause button in the story controls bar
    const PLAY_PATH  = '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 PAUSE_PATH = '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"]';

    function findStoryControlsRow() {
        for (const svg of document.querySelectorAll('svg')) {
            if (svg.querySelector(PLAY_PATH) || svg.querySelector(PAUSE_PATH)) {
                // The controls row is typically 3 levels up from the SVG
                return svg.parentNode?.parentNode?.parentNode ?? null;
            }
        }
        return null;
    }

    // Find the <section> that wraps the current story media
    function findStorySection() {
        return document.querySelector('section')
            || document.querySelector('div[role="dialog"]')
            || document.querySelector('main');
    }

    function storyButtonsPresent() {
        return document.querySelector('button[data-ig-helper][data-ig-story-injected]') !== null;
    }

    function createStoryButtons(sectionNode) {
        const context = { isStory: true, sectionNode };
        const [openBtn, dlBtn] = createButtonPair('white', context);
        // Mark them so we can detect presence without a dedicated class
        openBtn.dataset.igStoryInjected = '1';
        dlBtn.dataset.igStoryInjected = '1';
        return [openBtn, dlBtn];
    }

    // Inject buttons inline in the story controls bar next to mute/pause/more
    function injectStoryButtonsInline(controlsRow) {
        const sectionNode = findStorySection();
        const [openBtn, dlBtn] = createStoryButtons(sectionNode);
        controlsRow.appendChild(openBtn);
        controlsRow.appendChild(dlBtn);
    }

    // Fallback: inject a small floating pill when inline injection is not possible
    function injectStoryButtonsOverlay() {
        if (document.querySelector('div[data-ig-overlay]')) return;

        const sectionNode = findStorySection();
        const wrapper = document.createElement('div');
        wrapper.dataset.igOverlay = '1';
        wrapper.setAttribute('style', [
            'position:fixed', 'top:14px', 'right:64px', 'z-index:99999',
            'display:flex', 'align-items:center', 'gap:4px',
            'background:rgba(0,0,0,0.55)', 'border-radius:10px', 'padding:6px 8px',
        ].join(';'));

        const [openBtn, dlBtn] = createStoryButtons(sectionNode);
        wrapper.appendChild(openBtn);
        wrapper.appendChild(dlBtn);
        document.body.appendChild(wrapper);
    }

    function removeAllInjectedElements() {
        document.querySelectorAll('button[data-ig-helper]').forEach(b => b.remove());
        document.querySelectorAll('div[data-ig-overlay]').forEach(d => d.remove());
    }

    // ─── Main scan loop ───────────────────────────────────────────────────────

    setInterval(() => {
        const currentUrl = window.location.href;

        // Reset on navigation
        if (currentUrl !== previousUrl) {
            removeAllInjectedElements();
            storyRetryCount = 0;
            previousUrl = currentUrl;
            // Flush API cache on every story navigation so a stale cached response
            // for a previous story is never returned for the newly active one
            infoApiCache = {};
            capturedStoryVideoUrls = [];
        }

        const isStoryPage = currentUrl.includes('/stories/');
        const color = getIconColor();

        if (isStoryPage) {
            if (!storyButtonsPresent()) {
                const controlsRow = findStoryControlsRow();

                if (controlsRow) {
                    // Controls bar found — inject buttons inline
                    storyRetryCount = 0;
                    injectStoryButtonsInline(controlsRow);
                } else {
                    // Controls bar not rendered yet — wait before falling back
                    storyRetryCount++;
                    if (storyRetryCount > STORY_FALLBACK_AFTER) {
                        storyRetryCount = 0;
                        injectStoryButtonsOverlay();
                    }
                }
            }
        } else {
            // Feed / post page / profile: inject into every visible article
            for (const article of document.querySelectorAll('article')) {
                if (!articleHasButtons(article)) {
                    injectPostButtons(article, color);
                }
            }
        }

    }, SCAN_INTERVAL);

})();