Reddit Video Saver for iOS Safari

Auto scan and download Reddit videos on iOS Safari with touch-friendly UI and fallback download methods

目前為 2025-09-26 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Reddit Video Saver for iOS Safari
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @description  Auto scan and download Reddit videos on iOS Safari with touch-friendly UI and fallback download methods
// @author       ChatGPT-Pro
// @match        https://www.reddit.com/*
// @grant        GM_download
// @grant        GM_notification
// @grant        GM_xmlhttpRequest
// @run-at       document-idle
// ==/UserScript==

(function() {
  'use strict';

  // Helper utilities
  const utils = {
    sanitizeFilename(name) {
      return name.replace(/[^\w\s.-]/g, '_').slice(0, 80);
    },
    notify(msg) {
      if(typeof GM_notification !== 'undefined') {
        GM_notification({title: 'Reddit Video Saver', text: msg, timeout: 3000});
      } else {
        // Simple toast fallback for iOS Safari
        const toast = document.createElement("div");
        toast.style.cssText = `
          position: fixed;
          top: 20px;
          right: 20px;
          background: #ff4500;
          color: white;
          padding: 12px 16px;
          border-radius: 8px;
          z-index: 99999;
          font-size: 16px;
          font-weight: 600;
          font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
          box-shadow: 0 5px 15px rgba(0,0,0, 0.3);
        `;
        toast.textContent = msg;
        document.body.appendChild(toast);
        setTimeout(() => toast.remove(), 3000);
      }
    },
    openLinkToSave(url) {
      // Open video URL in new tab - user long press to save media
      window.open(url, '_blank');
      this.notify('Opened video in new tab - long press the video to save.');
    }
  };

  // Main video extractor and UI manager
  const scraper = {
    videoData: null, // Store extracted info from current page

    // Extract video URL(s) from Reddit JSON API or DOM
    async scanForVideo() {
      this.videoData = null;
      try {
        const postId = this.getPostIdFromUrl();
        if(!postId) return null;
        
        // Fetch Reddit JSON API data about the post
        const apiUrl = `https://www.reddit.com/comments/${postId}.json`;
        const response = await fetch(apiUrl);
        const json = await response.json();

        if(!json || !json[0] || !json[0].data.children.length) return null;

        const postData = json[0].data.children[0].data;

        // Check if Reddit video is available
        if(postData.secure_media?.reddit_video) {
          const videoUrl = postData.secure_media.reddit_video.fallback_url;
          const title = postData.title || 'reddit_video';
          this.videoData = {
            url: videoUrl,
            title: utils.sanitizeFilename(title)
          };
          return this.videoData;
        }

      } catch(e) {
        // Fallback: try find video element in DOM
        const videoEl = document.querySelector('video');
        if(videoEl && (videoEl.src || videoEl.currentSrc)) {
          this.videoData = {
            url: videoEl.currentSrc || videoEl.src,
            title: 'reddit_video'
          };
          return this.videoData;
        }
      }
    },

    getPostIdFromUrl() {
      const match = window.location.href.match(/\/comments\/([a-z0-9]+)/i);
      return match ? match[1] : null;
    },

    // Create UI container for the two fixed buttons
    createUi() {
      let container = document.getElementById('redditsaver-ui');
      if(container) return container;

      container = document.createElement('div');
      container.id = 'redditsaver-ui';
      container.style.cssText = `
        position: fixed; top: 4px; left: 50%; 
        transform: translateX(-50%);
        background: rgba(255, 69, 0, 0.95);
        border-radius: 10px;
        box-shadow: 0 4px 12px rgba(0,0,0,0.4);
        z-index: 100000;
        display: flex;
        gap: 12px;
        padding: 8px 16px;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
        -webkit-user-select:none;
      `;
      document.body.appendChild(container);
      return container;
    },

    // Build scan and download buttons with event handlers
    buildButtons() {
      const container = this.createUi();
      container.innerHTML = ''; // clear old buttons

      // Scan button: scans current page for video
      const scanBtn = document.createElement('button');
      scanBtn.textContent = 'Scan Current Page for Media';
      this.styleButton(scanBtn);
      scanBtn.onclick = async () => {
        scanBtn.disabled = true;
        scanBtn.textContent = 'Scanning...';
        await this.scanForVideo();
        if(this.videoData && this.videoData.url) {
          utils.notify('Media found on page!');
        } else {
          utils.notify('No media found on this page.');
        }
        scanBtn.textContent = 'Scan Current Page for Media';
        scanBtn.disabled = false;
      };
      container.appendChild(scanBtn);

      // Save button: saves video currently detected
      const saveBtn = document.createElement('button');
      saveBtn.textContent = 'Save Media on Page';
      this.styleButton(saveBtn);
      saveBtn.onclick = async () => {
        if(!this.videoData || !this.videoData.url) {
          utils.notify('No media detected yet! Tap "Scan" first.');
          return;
        }
        saveBtn.disabled = true;
        saveBtn.textContent = 'Saving...';

        // Try GM_download if available, fallback open tab
        if(typeof GM_download === 'function') {
          try {
            GM_download({
              url: this.videoData.url,
              name: this.videoData.title + '.mp4',
              onerror: () => {
                utils.notify('Download failed, opening video in new tab.');
                utils.openLinkToSave(this.videoData.url);
              },
              onload: () => utils.notify('Download started.')
            });
          } catch(e) {
            utils.openLinkToSave(this.videoData.url);
          }
        } else {
          utils.openLinkToSave(this.videoData.url);
        }

        setTimeout(() => {
          saveBtn.textContent = 'Save Media on Page';
          saveBtn.disabled = false;
        }, 1500);
      };
      container.appendChild(saveBtn);
    },

    styleButton(btn) {
      btn.style.cssText = `
        background-color: white;
        color: rgb(255, 69, 0);
        font-weight: 700;
        font-size: 14px;
        padding: 8px 12px;
        border-radius: 8px;
        border: none;
        cursor: pointer;
        user-select:none;
        min-width: 120px;
        box-shadow: 0 2px 8px rgba(255,69,0,0.4);
        transition: background-color 0.3s ease;
      `;
      btn.addEventListener('touchstart', () => btn.style.backgroundColor = 'rgba(255,69,0,0.1)');
      btn.addEventListener('touchend', () => btn.style.backgroundColor = 'white');
      btn.addEventListener('mouseenter', () => btn.style.backgroundColor = 'rgba(255,69,0,0.15)');
      btn.addEventListener('mouseleave', () => btn.style.backgroundColor = 'white');
    },

    // Set periodic rescans for SPA navigation or content changes
    setRescanOnNavigation() {
      let lastUrl = location.href;
      new MutationObserver(() => {
        if(location.href !== lastUrl) {
          lastUrl = location.href;
          this.scanForVideo();
        }
      }).observe(document.body, {childList: true, subtree: true});
    },

    init() {
      this.buildButtons();
      this.scanForVideo();
      this.setRescanOnNavigation();
    }
  };

  // Run on DOM ready
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => scraper.init());
  } else {
    scraper.init();
  }

})();