Instagram Max-Images Download ALL

任意の画像ポストページで最大解像度URLを一覧表示。一括タブ開き、一括ダウンロード機能あり ※表示されない場合はリロードしてください※

// ==UserScript==
// @name         Instagram Max-Images Download ALL
// @namespace    local.insta.tools
// @version      0.7.1
// @description  任意の画像ポストページで最大解像度URLを一覧表示。一括タブ開き、一括ダウンロード機能あり ※表示されない場合はリロードしてください※
// @match        https://www.instagram.com/*
// @grant        GM_addStyle
// @grant        GM_openInTab
// @grant        GM_download
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  const PANEL_ID = 'ig-maximg-panel';
  let lastPathname = location.pathname;
  let initialized = false;

  // ユーザー名あり/なしの /p/ パターンを両対応
  const isPostPage = () => /^\/(?:[^/]+\/)?p\/[^/]+\/?$/i.test(location.pathname);

  const addStyle = (css) => {
    if (typeof GM_addStyle === 'function') return GM_addStyle(css);
    const s = document.createElement('style'); s.textContent = css;
    document.head.appendChild(s); return s;
  };

  const removePanel = () => {
    const el = document.getElementById(PANEL_ID);
    if (el) el.remove();
  };

  const ensurePanel = () => {
    let panel = document.getElementById(PANEL_ID);
    if (panel) return panel;
    panel = document.createElement('div');
    panel.id = PANEL_ID;
    panel.innerHTML = `
      <div class="igmi-header">
        Max Image URLs
        <button class="igmi-btn igmi-open" title="全画像を別タブで開く">Open All</button>
        <button class="igmi-btn igmi-dl" title="全画像をダウンロード">Download All</button>
        <span class="igmi-count"></span>
      </div>
      <div class="igmi-body"><ul class="igmi-list"></ul></div>
    `;
    document.documentElement.appendChild(panel);
    addStyle(`
      #${PANEL_ID}{position:fixed;top:10px;right:10px;z-index:2147483647;background:#111;color:#fff;padding:10px 12px;border-radius:12px;box-shadow:0 6px 18px rgba(0,0,0,.35);font:12px/1.4 -apple-system,system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:360px}
      #${PANEL_ID} .igmi-header{font-weight:700;margin-bottom:6px;display:flex;align-items:center;gap:6px;flex-wrap:wrap}
      #${PANEL_ID} .igmi-body{max-height:300px;overflow:auto}
      #${PANEL_ID} .igmi-list{list-style:none;margin:0;padding:0}
      #${PANEL_ID} .igmi-list li{margin:4px 0;word-break:break-all}
      #${PANEL_ID} a{color:#9cd3ff;text-decoration:underline}
      #${PANEL_ID} .igmi-count{opacity:.8;margin-left:auto}
      #${PANEL_ID} .igmi-btn{background:#2b5cff;color:#fff;border:none;border-radius:8px;padding:3px 7px;cursor:pointer;font-weight:600}
      #${PANEL_ID} .igmi-btn:active{transform:translateY(1px)}
    `);
    return panel;
  };

  // 画像URL抽出(埋め込みJSON優先→DOMフォールバック)+重複除去
  const pickMaxFromCandidates = (cands, urlKey='url', wKey='width', hKey='height') => {
    if (!Array.isArray(cands) || !cands.length) return null;
    const sorted = [...cands].sort((a,b)=>((a[wKey]||0)*(a[hKey]||0))-((b[wKey]||0)*(b[hKey]||0)));
    return sorted.at(-1)?.[urlKey] || null;
  };
  const pickMaxFromDisplayResources = (arr) => {
    if (!Array.isArray(arr) || !arr.length) return null;
    const s = [...arr].sort((a,b)=>((a.config_width||a.width||0)*(a.config_height||a.height||0))-((b.config_width||b.width||0)*(b.config_height||b.height||0)));
    return s.at(-1)?.src || s.at(-1)?.srcset || null;
  };
  const findNodeByShortcode = (node, shortcode) => {
    try {
      if (!node) return null;
      if (typeof node === 'object') {
        if (node.shortcode === shortcode || node.code === shortcode) return node;
        for (const k in node) {
          const hit = findNodeByShortcode(node[k], shortcode);
          if (hit) return hit;
        }
      } else if (Array.isArray(node)) {
        for (const v of node) {
          const hit = findNodeByShortcode(v, shortcode);
          if (hit) return hit;
        }
      }
      return null;
    } catch { return null; }
  };
  const extractFromMediaNode = (media) => {
    const urls = [];
    const pushFromNode = (n) => {
      if (!n || n.is_video || n.video_versions) return;
      if (n.image_versions2?.candidates) {
        const u = pickMaxFromCandidates(n.image_versions2.candidates, 'url', 'width', 'height');
        if (u) urls.push(u);
        return;
      }
      const u2 = pickMaxFromDisplayResources(n.display_resources || n.thumbnail_resources);
      if (u2) { urls.push(u2); return; }
      if (typeof n.display_url === 'string') urls.push(n.display_url);
      else if (typeof n.thumbnail_src === 'string') urls.push(n.thumbnail_src);
    };
    const edges = media.edge_sidecar_to_children?.edges || media.carousel_media?.map(x=>({node:x})) || [];
    if (edges.length) edges.forEach(e=>pushFromNode(e.node||e));
    else pushFromNode(media);
    return urls;
  };
  const collectFromEmbeddedJSON_scoped = (shortcode) => {
    const urls = new Set();
    const tryParse = (txt) => {
      try {
        const j = JSON.parse(txt);
        const media = findNodeByShortcode(j, shortcode);
        if (media) extractFromMediaNode(media).forEach(u=>urls.add(u));
      } catch {}
    };
    const nextEl = document.querySelector('script#__NEXT_DATA__');
    if (nextEl?.textContent) tryParse(nextEl.textContent);
    document.querySelectorAll('script[type="application/json"]').forEach(s=>{
      if (s===nextEl) return;
      const t = s.textContent?.trim();
      if (t && t.length>2) tryParse(t);
    });
    return [...urls].filter(u=>/^https?:\/\//.test(u) && !/\.mp4(\?|$)/i.test(u));
  };
  const collectFromDom_scoped = () => {
    const set = new Set();
    const parseSrcsetMax = (srcset) => {
      if (!srcset) return null;
      try {
        const parts = srcset.split(',').map(x=>x.trim()).filter(Boolean);
        let best=null,bestW=-1;
        for (const p of parts) {
          const m=p.match(/^(\S+)\s+(\d+)w$/);
          if (m){const url=m[1],w=parseInt(m[2],10);if(w>bestW){bestW=w;best=url;}}
          else if (!best) best=p.split(' ')[0];
        }
        return best;
      } catch { return null; }
    };
    const mainArticle = document.querySelector('main article') || document.querySelector('article');
    if (mainArticle) {
      mainArticle.querySelectorAll('img').forEach(img=>{
        const u = parseSrcsetMax(img.getAttribute('srcset')) || img.getAttribute('src');
        if (u && !/\.mp4(\?|$)/i.test(u)) set.add(u);
      });
    }
    return [...set];
  };
  const normalizeKey = (u) => {
    try {
      const url = new URL(u);
      const cacheKey = url.searchParams.get('ig_cache_key');
      if (cacheKey) return `igk:${cacheKey}`;
      const fname = url.pathname.split('/').filter(Boolean).pop() || url.pathname;
      return `fn:${fname.toLowerCase()}`;
    } catch { return `raw:${u}`; }
  };
  const rank = (u) => {
    let score = 0;
    if (/\/s\d+x\d+\//.test(u)) score += 1;
    if (/[?&]stp=/.test(u)) score += 1;
    return score;
  };
  const dedupePreferBest = (urls) => {
    const sorted = [...urls].sort((a,b)=>rank(a)-rank(b));
    const seen = new Set(); const out = [];
    for (const u of sorted) {
      const key = normalizeKey(u);
      if (seen.has(key)) continue;
      seen.add(key);
      out.push(u);
    }
    return out;
  };

  // 描画 & ボタン
  const render = (urls) => {
    const panel = ensurePanel();
    const list = panel.querySelector('.igmi-list');
    list.innerHTML = '';
    urls.forEach((u, i) => {
      const li = document.createElement('li');
      const a = document.createElement('a');
      a.href = u; a.target = '_blank'; a.rel = 'noopener noreferrer';
      a.textContent = `画像${i + 1}`;
      li.appendChild(a); list.appendChild(li);
    });
    panel.querySelector('.igmi-count').textContent = `(${urls.length})`;

    panel.querySelector('.igmi-open').onclick = async () => {
      if (!urls.length) return;
      if (urls.length > 6 && !confirm(`画像を ${urls.length} 枚、順に新規タブで開きます。続行しますか?`)) return;
      for (const u of urls) {
        try {
          if (typeof GM_openInTab === 'function') GM_openInTab(u, { active: false, insert: true });
          else window.open(u, '_blank', 'noopener');
          await new Promise(r => setTimeout(r, 200));
        } catch {}
      }
    };

    panel.querySelector('.igmi-dl').onclick = async () => {
      if (!urls.length) return;
      if (urls.length > 6 && !confirm(`画像を ${urls.length} 枚ダウンロードします。続行しますか?`)) return;
      const baseName = (i, href) => {
        try {
          const u = new URL(href);
          const igk = u.searchParams.get('ig_cache_key');
          if (igk) return `ig_${i+1}_${igk}.jpg`;
          const name = (u.pathname.split('/').filter(Boolean).pop() || 'image').replace(/\.[a-z0-9]+$/i,'');
          return `ig_${i+1}_${name}.jpg`;
        } catch { return `ig_${i+1}.jpg`; }
      };
      for (let i=0;i<urls.length;i++) {
        const href = urls[i];
        try {
          if (typeof GM_download === 'function') {
            GM_download({ url: href, name: baseName(i, href) });
          } else {
            if (typeof GM_openInTab === 'function') GM_openInTab(href, { active: false, insert: true });
            else window.open(href, '_blank', 'noopener');
          }
          await new Promise(r => setTimeout(r, 300));
        } catch {}
      }
    };
  };

  // ページごとの実行フロー
  const runOnPostPage = () => {
    const m = location.pathname.match(/\/p\/([^/]+)/);
    const shortcode = m ? m[1] : null;
    let urls = [];
    if (shortcode) urls = collectFromEmbeddedJSON_scoped(shortcode);
    if (!urls.length) {
      urls = collectFromDom_scoped();
    }
    urls = dedupePreferBest(urls);
    render(urls);
  };

  const handleRouteChange = () => {
    const now = location.pathname;
    if (now === lastPathname) return;
    lastPathname = now;

    removePanel();
    if (isPostPage()) {
      setTimeout(runOnPostPage, 300);
    }
  };

  // 監視セットアップ
  const setupRoutingHooks = () => {
    if (initialized) return;
    initialized = true;

    const origPush = history.pushState;
    const origReplace = history.replaceState;
    history.pushState = function (...args) {
      const ret = origPush.apply(this, args);
      handleRouteChange(); return ret;
    };
    history.replaceState = function (...args) {
      const ret = origReplace.apply(this, args);
      handleRouteChange(); return ret;
    };

    window.addEventListener('popstate', handleRouteChange);
    document.addEventListener('visibilitychange', handleRouteChange);

    const mo = new MutationObserver(() => {
      handleRouteChange();
    });
    mo.observe(document.documentElement, { childList: true, subtree: true });

    if (isPostPage()) runOnPostPage();
  };

  const start = () => setupRoutingHooks();

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', start);
  } else {
    start();
  }
})();