Arca Live 图片和视频下载器

支援下载 Arca Live 贴文中的图片、GIF、MP4、WEBM(使用 GM_download 绕过 CORS)并自动命名为「板块_编号_0001~n」格式,快速下载、逐一下载两种模式。

// ==UserScript==
// @name         Arca Live Image and Video Downloader
// @name:zh-TW   Arca Live 圖片與影片下載器
// @name:zh-CN   Arca Live 图片和视频下载器
// @namespace    http://tampermonkey.net/
// @version      2.4
// @description  Supports downloading images, GIFs, MP4s, and WEBMs from Arca Live posts (using GM_download to bypass CORS), with automatic filename formatting as "Board_PostID_0001~n". Offers both fast download and sequential download modes.
// @description:zh-TW 支援下載 Arca Live 貼文中的圖片、GIF、MP4、WEBM(使用 GM_download 繞過 CORS)並自動命名為「板塊_編號_0001~n」格式,快速下載、逐一下載兩種模式。
// @description:zh-CN 支援下载 Arca Live 贴文中的图片、GIF、MP4、WEBM(使用 GM_download 绕过 CORS)并自动命名为「板块_编号_0001~n」格式,快速下载、逐一下载两种模式。
// @author       ChatGPT
// @match        https://arca.live/*
// @grant        GM_download
// @license MIT
// ==/UserScript==

(function () {
  'use strict';

  // 延遲函式,方便等待非同步流程
  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

  // 等待收藏按鈕出現,作為插入下載按鈕的參考點,最多等待 10 秒(50 次 * 200ms)
  const waitForScrapButton = async () => {
    for (let i = 0; i < 50; i++) {
      const scrapBtn = document.querySelector('form#scrapForm > button.scrap-btn');
      if (scrapBtn) return scrapBtn;
      await sleep(200);
    }
    return null;
  };

  // 從網址中解析板塊名稱與貼文 ID,例如 /b/umamusume/141070493
  const parseBoardInfo = () => {
    const match = location.pathname.match(/^\/b\/([^/]+)\/(\d+)/);
    if (!match) return { board: 'unknown', postId: 'unknown' };
    return { board: match[1], postId: match[2] };
  };

  // 收集貼文中所有可下載的媒體網址(圖片與影片)
  const collectMediaUrls = () => {
    const urls = new Set();

    // <img> 標籤圖片(優先取 data-src)
    document.querySelectorAll('.article-body img').forEach(img => {
      const src = img.getAttribute('data-src') || img.src;
      if (src) urls.add(src);
    });

    // <video><source> 影片來源
    document.querySelectorAll('.article-body video source').forEach(source => {
      if (source.src) urls.add(source.src);
    });

    // <video> 直接 src 屬性
    document.querySelectorAll('.article-body video').forEach(video => {
      if (video.src) urls.add(video.src);
    });

    // <a> 超連結,篩選 gif / mp4 / webm 檔案
    document.querySelectorAll('.article-body a[href]').forEach(a => {
      const href = a.href;
      if (/\.(gif|mp4|webm)(\?.*)?$/i.test(href)) {
        urls.add(href);
      }
    });

    return Array.from(urls);
  };

  // 建立快速下載模式切換按鈕,懸浮在主下載按鈕右側
  // 傳入主下載按鈕元素,包裝成相對定位容器,方便同時放入兩個元素
  const createFloatingFastToggle = (relativeToButton) => {
    const toggle = document.createElement('div');
    toggle.textContent = '⚡ 快速下載模式:❌';
    toggle.style.position = 'absolute';
    toggle.style.left = '100%'; // 緊貼主按鈕右側
    toggle.style.top = '0';
    toggle.style.marginLeft = '10px'; // 按鈕間隔
    toggle.style.padding = '4px 8px';
    toggle.style.backgroundColor = '#343a40';
    toggle.style.color = '#fff';
    toggle.style.borderRadius = '6px';
    toggle.style.whiteSpace = 'nowrap'; // 防止換行
    toggle.style.fontSize = '12px';
    toggle.style.cursor = 'pointer';
    toggle.style.userSelect = 'none'; // 避免文字被選取
    toggle.style.zIndex = '999'; // 置頂

    let fastMode = false; // 內部狀態,預設關閉

    // 點擊切換開關文字與狀態
    toggle.addEventListener('click', () => {
      fastMode = !fastMode;
      toggle.textContent = `⚡ 快速下載模式:${fastMode ? '✅' : '❌'}`;
    });

    // 建立一個相對定位的容器,包含主按鈕與切換按鈕
    const wrapper = document.createElement('div');
    wrapper.style.position = 'relative';
    wrapper.style.display = 'inline-block';
    wrapper.appendChild(relativeToButton);
    wrapper.appendChild(toggle);

    // 回傳容器與取得目前開關狀態的函式
    return { wrapper, getFastMode: () => fastMode };
  };

  // 下載媒體函式,支援快速模式(並行下載)與逐一下載
  // fastMode 為布林值,true 使用快速下載
  const downloadMedia = async (urls, button, fastMode) => {
    const { board, postId } = parseBoardInfo();
    let success = 0;

    if (fastMode) {
      // 快速模式:多個下載任務同時啟動,但每個任務之間仍維持 100ms 間隔
      const downloadTasks = urls.map((url, i) => {
        // 解析副檔名,若無則用 bin
        const ext = url.split('.').pop().split('?')[0].split('#')[0] || 'bin';
        // 檔名格式:板塊_貼文編號_四位數流水號.ext
        const filename = `${board}_${postId}_${String(i + 1).padStart(4, '0')}.${ext}`;

        return new Promise((resolve) => {
          try {
            GM_download({
              url,
              name: filename,
              saveAs: false,
              onload: () => {
                success++;
                button.textContent = `下載中 (${success}/${urls.length})`;
                resolve();
              },
              onerror: (err) => {
                console.warn(`❌ 無法下載: ${url}`, err);
                resolve();
              }
            });
          } catch (e) {
            console.error(`❌ GM_download 錯誤: ${url}`, e);
            resolve();
          }
        }).then(() => sleep(100)); // 保持間隔避免同時大量請求
      });

      await Promise.all(downloadTasks);
      button.textContent = '✅ 下載完成';

    } else {
      // 逐一下載模式:等待一張下載完成後才下載下一張
      for (let i = 0; i < urls.length; i++) {
        const url = urls[i];
        const ext = url.split('.').pop().split('?')[0].split('#')[0] || 'bin';
        const filename = `${board}_${postId}_${String(i + 1).padStart(4, '0')}.${ext}`;

        await new Promise((resolve) => {
          try {
            GM_download({
              url,
              name: filename,
              saveAs: false,
              onload: () => {
                success++;
                button.textContent = `下載中 (${success}/${urls.length})`;
                resolve();
              },
              onerror: (err) => {
                console.warn(`❌ 無法下載: ${url}`, err);
                resolve();
              }
            });
          } catch (e) {
            console.error(`❌ GM_download 錯誤: ${url}`, e);
            resolve();
          }
        });
      }

      button.textContent = '✅ 下載完成';
    }

    // 等待 30 秒後重置按鈕狀態(避免重複點擊衝突)
    setTimeout(() => {
      button.disabled = false;
      button.textContent = '📥 逐張圖片下載';
    }, 30000);
  };

  // 建立下載按鈕與快速模式切換按鈕容器
  const createDownloadButtonWithToggle = () => {
    // 下載按鈕
    const btn = document.createElement('button');
    btn.textContent = '📥 逐張圖片下載';
    btn.className = 'btn btn-arca btn-sm float-left mr-2';
    btn.type = 'button';

    // 產生包裝容器與快速模式取得函式
    const { wrapper, getFastMode } = createFloatingFastToggle(btn);

    // 按下下載按鈕的事件
    btn.addEventListener('click', async () => {
      btn.disabled = true;
      btn.textContent = '🔄 收集媒體中...';

      // 收集所有媒體網址
      const urls = collectMediaUrls();
      if (urls.length === 0) {
        alert('⚠️ 找不到任何圖片或影片');
        btn.disabled = false;
        btn.textContent = '📥 逐張圖片下載';
        return;
      }

      // 依快速模式開關狀態下載
      await downloadMedia(urls, btn, getFastMode());
    });

    return wrapper;
  };

  // 插入按鈕組合到收藏按鈕左側
  const insertButton = async () => {
    const scrapBtn = await waitForScrapButton();
    if (!scrapBtn) return;

    const downloadWrapper = createDownloadButtonWithToggle();
    scrapBtn.parentElement.insertBefore(downloadWrapper, scrapBtn);
  };

  // 啟動腳本
  insertButton();

})();