(修复版) 针对 Instagram 移动端网页进行重构适配,支持多图/视频下载。
// ==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">×</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);