Adds download and open-in-new-tab buttons to Instagram posts and stories.
// ==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);
})();