IG Helper (Mobile Reconstruct)

(修复版) 针对 Instagram 移动端网页进行重构适配,支持多图/视频下载。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name               IG Helper (Mobile Reconstruct)
// @name:zh-CN         IG小助手 (重构)
// @namespace          https://github.snkms.com/
// @version            1.0.2-mobile-reconstruct
// @description        (修复版) 针对 Instagram 移动端网页进行重构适配,支持多图/视频下载。
// @author             SN-Koarashi (Fixed by Assistant)
// @match              https://*.instagram.com/*
// @grant              GM_addStyle
// @grant              GM_xmlhttpRequest
// @grant              GM_openInTab
// @require            https://code.jquery.com/jquery-3.7.1.min.js
// @license            GNU GPLv3
// @run-at             document-idle
// ==/UserScript==


(function ($) {
    'use strict';

    /* --- 1. 样式定义 --- */
    const CSS = `
        /* 按钮容器 */
        .ig-helper-wrapper {
            position: absolute !important;
            top: 10px !important;
            right: 10px !important;
            z-index: 999 !important;
            display: flex !important;
            flex-direction: row !important;
            gap: 10px !important;
            pointer-events: auto !important;
            background: rgba(0, 0, 0, 0.45) !important;
            padding: 6px 10px !important;
            border-radius: 20px !important;
            backdrop-filter: blur(2px);
        }
        .ig-helper-btn {
            width: 28px !important;
            height: 28px !important;
            cursor: pointer !important;
            display: flex !important;
            align-items: center !important;
            justify-content: center !important;
            transition: transform 0.1s;
            opacity: 0.95;
        }
        .ig-helper-btn:active { transform: scale(0.9); opacity: 1; }
        .ig-helper-btn svg {
            width: 20px !important;
            height: 20px !important;
            fill: #ffffff !important;
            filter: drop-shadow(0 1px 2px rgba(0,0,0,0.8));
        }
        /* 全屏查看器 */
        #ig-helper-overlay {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0,0,0,0.95); z-index: 20000;
            display: flex; align-items: center; justify-content: center;
        }
        #ig-helper-overlay img, #ig-helper-overlay video {
            max-width: 100%; max-height: 100%; object-fit: contain;
        }
        #ig-helper-close {
            position: absolute; top: 20px; right: 20px;
            color: white; font-size: 30px; font-weight: bold; padding: 20px; z-index: 20001; cursor: pointer;
        }
        /* 批量下载 UI */
        #ig-batch-overlay {
            position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
            background: rgba(0,0,0,0.8); z-index: 30000;
            display: flex; justify-content: center; align-items: center;
        }
        .ig-batch-modal {
            background: #ffffff; width: 92%; max-width: 550px;
            max-height: 85vh; border-radius: 12px;
            display: flex; flex-direction: column; overflow: hidden;
            position: relative; box-shadow: 0 10px 25px rgba(0,0,0,0.5);
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
        }
        .ig-batch-close-btn {
            position: absolute; right: 15px; top: 10px; font-size: 28px;
            cursor: pointer; color: #555; z-index: 2; line-height: 1;
        }
        .ig-batch-close-btn:hover { color: #000; }
        .ig-batch-header {
            padding: 20px; padding-top: 25px; border-bottom: 1px solid #eaeaea;
            display: flex; flex-wrap: wrap; gap: 10px; align-items: center; background: #fafafa;
        }
        .ig-batch-header button {
            padding: 8px 12px; cursor: pointer; border: 1px solid #0095f6;
            background: #0095f6; color: #fff; border-radius: 6px;
            font-weight: 600; font-size: 13px; transition: background 0.2s;
            flex: 1; min-width: 200px;
        }
        .ig-batch-header button:hover { background: #0077c5; }
        .ig-batch-header button:disabled { background: #a0c3db; border-color: #a0c3db; cursor: not-allowed; }
        .ig-batch-header label {
            font-size: 14px; color: #222; cursor: pointer;
            display: flex; align-items: center; gap: 5px; font-weight: bold; width: 100%;
            margin-bottom: 5px;
        }
        .ig-batch-header input[type="checkbox"] { transform: scale(1.3); }
        .ig-batch-body {
            padding: 15px; overflow-y: auto; display: flex; flex-direction: column; gap: 12px;
            background: #f0f2f5; flex: 1;
            /* 核心修复:隔离滚动链,防止划动时影响背后网页,且不触及 body 的 overflow 属性 */
            overscroll-behavior: contain;
            -webkit-overflow-scrolling: touch;
        }
        .ig-preview-item {
            display: flex; align-items: center; background: #fff; padding: 10px;
            border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); transition: background 0.2s;
        }
        .ig-preview-item:hover { background: #f9f9f9; }
        .ig-preview-checkbox { margin-right: 15px; margin-left: 5px; transform: scale(1.4); cursor: pointer;}
        .ig-preview-content {
            flex: 1; display: flex; align-items: center; gap: 12px; cursor: pointer;
        }
        .ig-preview-content img {
            width: 70px; height: 70px; object-fit: cover; border-radius: 6px; border: 1px solid #ddd;
        }
        .ig-preview-info { font-size: 13px; color: #555; word-break: break-all; }
        .ig-preview-type {
            font-size: 11px; padding: 3px 6px; background: #e0e0e0;
            border-radius: 4px; color: #333; font-weight: bold;
        }
        .ig-post-id-badge {
            background: #eef2ff; color: #4f46e5; padding: 2px 8px; 
            border-radius: 6px; font-family: monospace; font-size: 15px;
            border: 1px solid #c7d2fe;
        }
    `;

    const ICONS = {
        DL: '<svg viewBox="0 0 24 24"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>',
        BATCH: '<svg viewBox="0 0 24 24"><path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H8V4h12v12zM10 9h8v2h-8zm0 3h4v2h-4zm0-6h8v2h-8z"/></svg>',
        VIEW: '<svg viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>'
    };

    GM_addStyle(CSS);

    /* --- 2. 注入逻辑 --- */
    setInterval(findAndInject, 800);

    function findAndInject() {
        let $medias = $('article img, article video, div[role="article"] img, div[role="article"] video');

        $medias.each(function () {
            let $media = $(this);
            
            if ($media.is('img')) {
                if (($media.width() < 100) || ($media.height() < 100)) return;
                if ($media.closest('header').length > 0) return;
            }
            
            let $wrapper = $media.parent();
            if ($wrapper.is('picture')) $wrapper = $wrapper.parent();
            if ($wrapper.find('.ig-helper-wrapper').length > 0) return;
            if ($media.is('img') && $wrapper.children('video').length > 0) return;

            injectButtons($wrapper, $media);
        });
    }

    function injectButtons($container, $mediaContext) {
        if ($container.css('position') === 'static') {
            $container.css('position', 'relative');
        }

        let $wrapper = $(`<div class="ig-helper-wrapper"></div>`);
        $wrapper.append(`<div class="ig-helper-btn" title="下载(当前)">${ICONS.DL}</div>`)
                .append(`<div class="ig-helper-btn" title="批量下载(全帖)">${ICONS.BATCH}</div>`)
                .append(`<div class="ig-helper-btn" title="全屏查看">${ICONS.VIEW}</div>`);

        $container.append($wrapper);
        let $article = $container.closest('article, div[role="article"]');
        bindEvents($wrapper, $article);
    }

    /* --- 3. 事件绑定 --- */
    function bindEvents($btnWrapper, $post) {
        const killEvent = (e) => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); };

        $btnWrapper.on('click touchstart', '.ig-helper-btn:nth-child(1)', async function (e) {
            killEvent(e);
            let media = await getActiveMediaUrl($post, $btnWrapper);
            if (media.url) forceDownload(media.url, media.filename);
            else alert('无法获取当前资源');
        });

        $btnWrapper.on('click touchstart', '.ig-helper-btn:nth-child(2)', async function (e) {
            killEvent(e);
            openBatchDownloadModal($post);
        });

        $btnWrapper.on('click touchstart', '.ig-helper-btn:nth-child(3)', async function (e) {
            killEvent(e);
            let media = await getActiveMediaUrl($post, $btnWrapper);
            if (media.url) showOverlay(media.url, media.isVideo);
            else alert('未找到当前资源');
        });
    }

    /* --- 4. 批量下载提取与面板 --- */
    function extractShortcode($post) {
        let shortcode = null;
        let regex = /\/(?:p|reel|tv)\/([a-zA-Z0-9_-]+)/;
        $post.find('a').each(function() {
            let href = $(this).attr('href');
            if (href) {
                let match = href.match(regex);
                if (match && match[1]) { shortcode = match[1]; return false; }
            }
        });
        if (!shortcode) {
            let match = location.pathname.match(regex);
            if (match && match[1]) shortcode = match[1];
        }
        return shortcode;
    }

    async function openBatchDownloadModal($post) {
        let shortcode = extractShortcode($post);
        if (!shortcode) {
            alert('无法获取该帖子的链接,请尝试点击帖子进入详情页后重试。');
            return;
        }
        let username = $post.find('header a').first().text() || 'instagram';

        let $loading = $(`<div id="ig-batch-overlay"><div style="color:white;font-size:20px;font-weight:bold;">Fetching Media Data...</div></div>`);
        $('body').append($loading);

        try {
            let itemsData = await fetchPostData(shortcode);
            $loading.remove();
            
            if (!itemsData || itemsData.length === 0) throw new Error("No media data extracted.");

            let items = itemsData.map((node, idx) => {
                return {
                    id: node.id,
                    url: node.url,
                    preview: node.preview || node.url,
                    isVideo: node.isVideo,
                    filename: `${username}_${shortcode}_${idx}.${node.isVideo ? 'mp4' : 'jpg'}`
                };
            });
            renderBatchModal(items, shortcode);
        } catch(e) {
            $loading.remove();
            console.error(e);
            alert('获取媒体数据失败!\n\n原因可能为:\n1. 网络限制或该帖子为私密权限。\n2. Instagram 防御机制临时阻止了请求。\n\n建议刷新网页后重试。');
        }
    }

    function renderBatchModal(items, shortcode) {
        let html = `
        <div id="ig-batch-overlay">
            <div class="ig-batch-modal">
                <div class="ig-batch-close-btn">&times;</div>
                <div class="ig-batch-header">
                    <!-- Post ID 信息 -->
                    <div style="width: 100%; font-size: 14px; color: #555; margin-bottom: 8px; padding-bottom: 12px; border-bottom: 1px dashed #ddd;">
                        📌 <strong>Post ID:</strong> <span class="ig-post-id-badge">${shortcode}</span>
                    </div>
                    
                    <label>
                        <input type="checkbox" id="ig-batch-select-all" checked /> 
                        全选 / Select All
                    </label>
                    <button id="ig-batch-dl-selected">Download Selected Resources</button>
                    <button id="ig-batch-dl-all">Download All Resources</button>
                </div>
                <div class="ig-batch-body">
                    ${items.map((item, i) => `
                        <div class="ig-preview-item" data-index="${i}">
                            <input type="checkbox" class="ig-preview-checkbox" value="${i}" checked />
                            <div class="ig-preview-content" title="点击直接下载此文件">
                                <img src="${item.preview}" />
                                <div class="ig-preview-info">
                                    <span class="ig-preview-type" style="background:${item.isVideo?'#ffdbdb':'#e0e0e0'}">${item.isVideo ? 'Video' : 'Image'}</span>
                                    <div style="margin-top: 6px; font-size:12px; color:#888;">${item.filename}</div>
                                </div>
                            </div>
                        </div>
                    `).join('')}
                </div>
            </div>
        </div>`;
        
        let $modal = $(html);
        
        // 核心修复:直接挂载,不再修改 body 的 overflow,防止 iOS/移动端页面跳到顶端!
        $('body').append($modal); 

        const closeModal = () => { $modal.remove(); };

        $modal.find('.ig-batch-close-btn').on('click', closeModal);
        $modal.on('click', (e) => { if (e.target.id === 'ig-batch-overlay') closeModal(); });

        $modal.find('#ig-batch-select-all').on('change', function() {
            let checked = $(this).is(':checked');
            $modal.find('.ig-preview-checkbox').prop('checked', checked);
        });

        $modal.find('.ig-preview-checkbox').on('change', function() {
            let allChecked = $modal.find('.ig-preview-checkbox:not(:checked)').length === 0;
            $modal.find('#ig-batch-select-all').prop('checked', allChecked);
        });

        $modal.find('.ig-preview-content').on('click', function() {
            let index = $(this).parent().data('index');
            let item = items[index];
            if (confirm(`是否下载该文件?\n\n${item.filename}`)) { forceDownload(item.url, item.filename); }
        });

        $modal.find('#ig-batch-dl-selected').on('click', async function() {
            let selectedIndexes =[];
            $modal.find('.ig-preview-checkbox:checked').each(function() { selectedIndexes.push(parseInt($(this).val())); });
            if(selectedIndexes.length === 0) { alert('请先选择要下载的资源!'); return; }

            let $btn = $(this);
            $btn.text('Downloading...').prop('disabled', true);
            for(let i of selectedIndexes) { await forceDownload(items[i].url, items[i].filename); }
            $btn.text('Download Selected Resources').prop('disabled', false);
        });

        $modal.find('#ig-batch-dl-all').on('click', async function() {
            let $btn = $(this);
            $btn.text('Downloading...').prop('disabled', true);
            for(let item of items) { await forceDownload(item.url, item.filename); }
            $btn.text('Download All Resources').prop('disabled', false);
        });
    }

    /* --- 5. 通用功能与高层 API 提取引擎 --- */
    function fetchSafe(url, headers) {
        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: url,
                headers: headers,
                onload: (res) => {
                    resolve({ ok: res.status >= 200 && res.status < 300, status: res.status, text: res.responseText });
                },
                onerror: (e) => {
                    resolve({ ok: false, status: 0, text: '' });
                }
            });
        });
    }

    async function fetchPostData(shortcode) {
        const headers = {
            "X-IG-App-ID": "936619743392459",
            "X-ASBD-ID": "198387",
            "X-IG-WWW-Claim": "0",
            "X-Requested-With": "XMLHttpRequest",
            "Accept": "application/json, text/plain, */*"
        };
        
        let match = document.cookie.match(/(?:^| )csrftoken=([^;]+)/);
        if (match && match[1]) headers["X-CSRFToken"] = match[1];

        let rawData = null;

        const requests =[
            `https://www.instagram.com/graphql/query/?doc_id=10015901848480474&variables=%7B%22shortcode%22%3A%22${shortcode}%22%7D`,
            `https://www.instagram.com/graphql/query/?doc_id=8845758582119845&variables=%7B%22shortcode%22%3A%22${shortcode}%22%7D`,
            `https://www.instagram.com/api/v1/media/by/shortcode/${shortcode}/`,
            `https://www.instagram.com/p/${shortcode}/?__a=1&__d=dis`
        ];

        for (let url of requests) {
            try {
                let res = await fetchSafe(url, headers);
                if (!res.ok) continue;
                
                let text = res.text;
                if (!text || text.startsWith('<!DOCTYPE html>')) continue; 
                
                let json;
                try { json = JSON.parse(text); } catch(e) { continue; }
                if (json.require_login) continue; 
                
                if (json.items && json.items.length > 0) {
                    rawData = json;
                    break;
                }
                if (json.data && (json.data.xdt_shortcode_media || json.data.shortcode_media)) {
                    rawData = { graphql: json.data };
                    break;
                }
                if (json.graphql && json.graphql.shortcode_media) {
                    rawData = json;
                    break;
                }
            } catch (e) {
                console.warn("[IG Helper] API Layer Missed: " + url, e);
            }
        }

        if (!rawData) {
            throw new Error("All API extraction methods failed.");
        }

        return normalizeMediaData(rawData);
    }

    function normalizeMediaData(data) {
        let results =[];
        
        let media = null;
        if (data.graphql) {
            media = data.graphql.xdt_shortcode_media || data.graphql.shortcode_media;
        }

        if (media) {
            let nodes = media.edge_sidecar_to_children ? media.edge_sidecar_to_children.edges.map(e => e.node) : [media];
            for (let node of nodes) {
                results.push({
                    isVideo: !!node.is_video,
                    url: node.video_url || node.display_url,
                    preview: node.display_url || node.thumbnail_src || node.video_url,
                    id: node.id || "media"
                });
            }
            return results;
        }
        
        if (data.items && data.items.length > 0) {
            let item = data.items[0];
            let carousel = item.carousel_media || [item]; 
            
            for (let node of carousel) {
                let isVideo = item.media_type === 2 || node.media_type === 2 || !!node.video_versions;
                
                let videoUrl = null;
                if (node.video_versions && node.video_versions.length > 0) {
                    videoUrl = node.video_versions[0].url;
                }
                
                let imageUrl = null;
                if (node.image_versions2 && node.image_versions2.candidates && node.image_versions2.candidates.length > 0) {
                    let sorted = node.image_versions2.candidates.sort((a,b) => b.width - a.width);
                    imageUrl = sorted[0].url;
                }
                
                results.push({
                    isVideo: isVideo,
                    url: videoUrl || imageUrl,
                    preview: imageUrl || videoUrl,
                    id: node.id || item.id || "media"
                });
            }
            return results;
        }
        
        return[];
    }

    async function getActiveMediaUrl($post, $btnWrapper) {
        let username = $post.find('header a').first().text() || 'instagram';
        let timestamp = Date.now();
        
        let $container = $btnWrapper.parent();
        let $localMedia = $container.find('video, img').not('.ig-helper-btn svg, .ig-helper-btn img').first();
        
        let activeIndex = 0;
        if ($localMedia.length > 0) {
            let $allValidMedias = $post.find('img, video').filter(function() {
                let $m = $(this);
                if ($m.closest('.ig-helper-wrapper').length) return false;
                if ($m.closest('header').length) return false;
                if ($m.width() < 100 || $m.height() < 100) return false;
                return true;
            });
            activeIndex = $allValidMedias.index($localMedia);
            if (activeIndex === -1) activeIndex = 0;
        }

        if ($localMedia.length > 0) {
            if ($localMedia.is('video') && $localMedia.attr('src')) {
                let src = $localMedia.attr('src');
                if (src && !src.startsWith('blob:')) {
                    return { url: src, filename: `${username}_video_${timestamp}_${activeIndex}.mp4`, isVideo: true };
                }
            }
            if ($localMedia.is('img')) {
                let src = $localMedia.attr('src');
                if ($localMedia.attr('srcset')) {
                    let sets = $localMedia.attr('srcset').split(',');
                    src = sets[sets.length - 1].trim().split(' ')[0];
                }
                if (src && !src.includes('data:image')) {
                    return { url: src, filename: `${username}_photo_${timestamp}_${activeIndex}.jpg`, isVideo: false };
                }
            }
        }

        let shortcode = extractShortcode($post);
        if (shortcode) {
            try {
                let itemsData = await fetchPostData(shortcode);
                if (itemsData && itemsData.length > 0) {
                    let targetResource = itemsData[activeIndex] || itemsData[0];
                    return { 
                        url: targetResource.url, 
                        filename: `${username}_${shortcode}_${activeIndex}.${targetResource.isVideo ? 'mp4' : 'jpg'}`, 
                        isVideo: targetResource.isVideo 
                    };
                }
            } catch (e) { console.error("API Fallback Failed", e); }
        }
        
        return { url: null };
    }

    function forceDownload(url, filename) {
        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: "GET", url: url, responseType: "blob",
                onload: function(response) {
                    let blobUrl = URL.createObjectURL(response.response);
                    let a = document.createElement('a');
                    a.href = blobUrl; a.download = filename;
                    document.body.appendChild(a); a.click();
                    setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove(); }, 1000);
                    resolve(true);
                },
                onerror: () => {
                    window.open(url, '_blank');
                    resolve(false);
                }
            });
        });
    }

    function showOverlay(url, isVideo) {
        let content = isVideo ? 
            `<video src="${url}" controls autoplay style="max-width:100%;max-height:100%"></video>` : 
            `<img src="${url}" />`;
        
        let $overlay = $(`<div id="ig-helper-overlay"><div id="ig-helper-close">×</div>${content}</div>`);
        $overlay.on('click', function(e) { if(e.target === this || e.target.id === 'ig-helper-close') $(this).remove(); });
        $('body').append($overlay);
    }

})(jQuery);