Reddit Video Saver iOS Safari Enhanced

Scan and save Reddit videos on mobile Safari with native iOS save compatibility and touch-friendly UI

当前为 2025-09-26 提交的版本,查看 最新版本

// ==UserScript==
// @name         Reddit Video Saver iOS Safari Enhanced
// @namespace    http://tampermonkey.net/
// @version      3.0.0
// @description  Scan and save Reddit videos on mobile Safari with native iOS save compatibility and touch-friendly UI
// @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';

  const isIOS =
    /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;

  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 {
        const toast = document.createElement("div");
        toast.style.cssText = `
          position: fixed;
          top: 20px;
          right: 20px;
          background: #ff4500;
          color: white;
          padding: 12px 16px;
          border-radius: 12px;
          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);
          max-width: 90vw;
          word-wrap: break-word;
        `;
        toast.textContent = msg;
        document.body.appendChild(toast);
        setTimeout(() => toast.remove(), 3000);
      }
    },
    openLinkToSave(url) {
      // iOS Safari: open in new tab, instruct long-press to save
      window.open(url, '_blank', 'noopener');
      this.notify('Opened video in new tab - long press the video to save.');
    }
  };

  const scraper = {
    videoData: null,

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

    fetchRedditJson(postId) {
      return new Promise((resolve, reject) => {
        if(typeof GM_xmlhttpRequest !== 'undefined') {
          GM_xmlhttpRequest({
            method: 'GET',
            url: `https://www.reddit.com/comments/${postId}.json`,
            headers: { 'User-Agent': 'Mozilla/5.0', 'Accept': 'application/json' },
            onload: res => {
              if(res.status === 200) {
                try {
                  resolve(JSON.parse(res.responseText));
                } catch(e) {
                  reject(e);
                }
              } else reject(new Error(`Status ${res.status}`));
            },
            onerror: err => reject(err)
          });
        } else {
          // fallback to fetch but may have CORS issues on iOS Safari
          fetch(`https://www.reddit.com/comments/${postId}.json`)
            .then(r => r.json())
            .then(resolve)
            .catch(reject);
        }
      });
    },

    async scanForVideo() {
      this.videoData = null;
      console.log("[Reddit Saver] Scanning for video...");
      const postId = this.getPostIdFromUrl();
      if(!postId) {
        console.warn("[Reddit Saver] No post ID found in URL.");
        return null;
      }
      try {
        const json = await this.fetchRedditJson(postId);
        if(!json || !json[0]?.data?.children?.length) throw new Error('Invalid JSON structure');

        const post = json[0].data.children[0].data;
        // Try secure_media or media reddit_video entries
        let videoUrl = post.secure_media?.reddit_video?.fallback_url || post.media?.reddit_video?.fallback_url;

        // If crosspost exists, check there
        if(!videoUrl && post.crosspost_parent_list?.length) {
          const cross = post.crosspost_parent_list[0];
          videoUrl = cross?.secure_media?.reddit_video?.fallback_url || cross?.media?.reddit_video?.fallback_url;
        }

        if(videoUrl) {
          this.videoData = {
            url: videoUrl,
            title: utils.sanitizeFilename(post.title || 'reddit_video'),
          };
          console.log("[Reddit Saver] Video found:", this.videoData);
          utils.notify('Video detected on this Reddit post.');
          return this.videoData;
        }
      } catch(e) {
        console.error("[Reddit Saver] Fetch or parse error:", e);
      }

      // Try fallback: select visible video in DOM
      const videoEl = document.querySelector('video');
      if(videoEl && (videoEl.src || videoEl.currentSrc)) {
        this.videoData = {
          url: videoEl.currentSrc || videoEl.src,
          title: 'reddit_video_dom',
        };
        console.log("[Reddit Saver] Video found in DOM:", this.videoData);
        utils.notify('Video found via DOM scanning.');
        return this.videoData;
      }

      utils.notify('No video found on current page.');
      return null;
    },

    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: 0; left: 0; right: 0;
        background: rgba(255, 69, 0, 0.95);
        padding: 10px 0;
        z-index: 9999999999;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
        display: flex; justify-content: center; gap: 15px;
        user-select: none;
        -webkit-user-select: none;
      `;
      document.body.appendChild(container);
      return container;
    },

    addButtons() {
      const container = this.createUi();
      container.innerHTML = '';

      const scanBtn = document.createElement('button');
      scanBtn.textContent = 'Scan Current Page for Media';
      this.styleButton(scanBtn);
      scanBtn.ontouchstart = scanBtn.onclick = async (e) => {
        e.preventDefault();
        scanBtn.disabled = true;
        scanBtn.textContent = 'Scanning...';
        await this.scanForVideo();
        scanBtn.textContent = 'Scan Current Page for Media';
        scanBtn.disabled = false;
      };

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

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

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

      container.appendChild(scanBtn);
      container.appendChild(saveBtn);
    },

    styleButton(btn) {
      btn.style.cssText = `
        background-color: white;
        color: rgb(255, 69, 0);
        font-weight: 700;
        font-size: 16px;
        padding: 12px 18px;
        border-radius: 12px;
        border: none;
        cursor: pointer;
        min-width: 150px;
        box-shadow: 0 4px 15px rgba(255,69,0,0.5);
        touch-action: manipulation;
        -webkit-tap-highlight-color: transparent;
        user-select:none;
      `;
      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');
    },

    rescanOnNavigation() {
      let lastURL = location.href;
      new MutationObserver(() => {
        if(location.href !== lastURL) {
          lastURL = location.href;
          console.log("[Reddit Saver] URL changed, rescanning...");
          this.scanForVideo();
        }
      }).observe(document.body, {childList: true, subtree: true});

      window.addEventListener('popstate', () => {
        setTimeout(() => this.scanForVideo(), 500);
      });
    },

    init() {
      console.log("[Reddit Saver] Initializing UI and scanning...");
      this.addButtons();
      this.scanForVideo();
      this.rescanOnNavigation();
    }
  };

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