Story Downloader - Facebook and Instagram (Hardened)

Download stories (videos and images) from Facebook and Instagram. Hardened selectors, robust detection, visible feedback.

当前为 2025-10-03 提交的版本,查看 最新版本

// ==UserScript==
// @name         Story Downloader - Facebook and Instagram (Hardened)
// @namespace    https://github.com/oscar370
// @version      2.1.1
// @description  Download stories (videos and images) from Facebook and Instagram. Hardened selectors, robust detection, visible feedback.
// @author       oscar370 (patched)
// @match        *://*.facebook.com/*
// @match        *://*.instagram.com/*
// @grant        none
// @license      GPL3
// ==/UserScript==

(() => {
  "use strict";

  const POLL_INTERVAL_MS = 250;
  const MAX_POLL_ATTEMPTS = 120; // 30s total
  const TOAST_ZINDEX = 2147483647;
  const isDev = false;

  class StoryDownloader {
    constructor() {
      this.mediaUrl = null;
      this.detectedVideo = false;
      this._poller = null;
      this._toastTimer = null;
      this.init();
    }

    init() {
      this.log("init");
      this.injectStyles();
      this.setupObservers();
      this.checkPageStructure();
      window.addEventListener("popstate", () => this.checkPageStructure());
    }

    log(...args) {
      if (isDev) console.log("[StoryDownloader]", ...args);
    }

    // -------------------------
    // Page structure detection
    // -------------------------
    checkPageStructure() {
      const onStory = this._isStoryUrl() || this._looksLikeStoryModal();
      if (onStory) {
        this.startPollingForButton();
      } else {
        this.removeAllUI();
        this.stopPollingForButton();
      }
    }

    _isStoryUrl() {
      try {
        return /(\/stories\/|\/reels\/|\/stories$|\/stories\/\d+)/i.test(location.href);
      } catch (e) {
        return false;
      }
    }

    _looksLikeStoryModal() {
      try {
        // Dialog with media
        const dialog = document.querySelector('div[role="dialog"], div[role="presentation"]');
        if (dialog) {
          if (dialog.querySelector("video, img, [style*='background-image']")) return true;
        }
        // facebook story pagelets/aria labels
        if (document.querySelector('[data-pagelet*="Story"], [aria-label*="Story"]')) return true;
      } catch (e) {
        /* ignore */
      }
      return false;
    }

    // -------------------------
    // Styles and UI
    // -------------------------
    injectStyles() {
      if (document.getElementById("sd-styles")) return;
      const style = document.createElement("style");
      style.id = "sd-styles";
      style.textContent = `
        #downloadBtn {
          border: none;
          background: rgba(0,0,0,0.36);
          color: white;
          cursor: pointer;
          z-index: ${TOAST_ZINDEX};
          padding: 6px 8px;
          border-radius: 6px;
          display: inline-flex;
          align-items: center;
          gap: 6px;
          font-size: 12px;
          backdrop-filter: blur(4px);
        }
        #sd-floating-btn {
          z-index: ${TOAST_ZINDEX};
          box-shadow: 0 6px 18px rgba(0,0,0,0.3);
        }
        #sd-toast {
          position: fixed;
          right: 12px;
          top: 12px;
          z-index: ${TOAST_ZINDEX};
          background: rgba(0,0,0,0.85);
          color: #fff;
          padding: 8px 12px;
          border-radius: 6px;
          font-size: 13px;
          max-width: 320px;
          word-break: break-word;
        }
      `;
      document.head.appendChild(style);
    }

    showToast(text, duration = 2500) {
      try {
        let toast = document.getElementById("sd-toast");
        if (!toast) {
          toast = document.createElement("div");
          toast.id = "sd-toast";
          document.body.appendChild(toast);
        }
        toast.textContent = text;
        if (this._toastTimer) clearTimeout(this._toastTimer);
        if (duration > 0) {
          this._toastTimer = setTimeout(() => {
            try { toast.remove(); } catch (e) {}
            this._toastTimer = null;
          }, duration);
        }
      } catch (e) {
        this.log("toast error", e);
      }
    }

    removeAllUI() {
      try {
        const b = document.getElementById("downloadBtn"); if (b) b.remove();
        const f = document.getElementById("sd-floating-btn"); if (f) f.remove();
        const t = document.getElementById("sd-toast"); if (t) t.remove();
      } catch (e) { /* ignore */ }
    }

    // -------------------------
    // Polling & Button inject
    // -------------------------
    startPollingForButton() {
      if (this._poller) return;
      let attempts = 0;
      this._poller = setInterval(() => {
        attempts++;
        // sanity: if page no longer looks like story, stop
        if (!this._isStoryUrl() && !this._looksLikeStoryModal()) {
          this.stopPollingForButton();
          return;
        }
        if (document.getElementById("downloadBtn") || document.getElementById("sd-floating-btn")) {
          // already present
          if (attempts > 0) { /* let it exist */ }
          return;
        }
        const created = this._tryCreateButtonAtTopBar() || this._tryCreateButtonInDialog() || null;
        if (created) {
          this.log("created button", created);
          return;
        }
        if (attempts >= MAX_POLL_ATTEMPTS) {
          this.log("fallback: injecting floating button");
          this._injectFloatingButton();
          // keep polling in case a top-bar becomes available later; don't clear
        }
      }, POLL_INTERVAL_MS);
    }

    stopPollingForButton() {
      if (this._poller) {
        clearInterval(this._poller);
        this._poller = null;
      }
    }

    _tryCreateButtonAtTopBar() {
      // robust selection list
      const selectors = [
        'header[role="banner"]',
        '[role="toolbar"]',
        '[data-testid="story-header"]',
        'div[aria-label*="Story"]',
        'div[style*="position: absolute"][style*="top"]',
        'div[style*="position: fixed"][style*="top"]',
        'div[role="navigation"]'
      ];
      for (const sel of selectors) {
        const nodes = Array.from(document.querySelectorAll(sel));
        for (const node of nodes) {
          if (!(node instanceof HTMLElement)) continue;
          if (node.offsetParent === null) continue; // not visible
          // append button if not present
          try {
            const btn = this._createButtonElement();
            node.appendChild(btn);
            return btn;
          } catch (e) {
            this.log("append failed", e);
            try {
              const btn = this._createButtonElement();
              node.insertBefore(btn, node.firstChild);
              return btn;
            } catch (e2) {
              this.log("insertBefore failed", e2);
            }
          }
        }
      }
      return null;
    }

    _tryCreateButtonInDialog() {
      const dialog = document.querySelector('div[role="dialog"], div[role="presentation"]');
      if (!dialog || !(dialog instanceof HTMLElement) || dialog.offsetParent === null) return null;
      // try to place in dialog toolbar or top-right
      const candidate = dialog.querySelector('[role="toolbar"], header, div[style*="position: absolute"], div[style*="position: fixed"]');
      if (candidate && candidate instanceof HTMLElement && candidate.offsetParent !== null) {
        const btn = this._createButtonElement();
        try {
          candidate.appendChild(btn);
        } catch (e) {
          candidate.insertBefore(btn, candidate.firstChild);
        }
        return btn;
      }
      return null;
    }

    _createButtonElement() {
      if (document.getElementById("downloadBtn")) return document.getElementById("downloadBtn");
      const btn = document.createElement("button");
      btn.id = "downloadBtn";
      btn.title = "Download story";
      btn.innerHTML = `
        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
          <path d="M8 0a2 2 0 0 0-2 2v6H3.5a.5.5 0 0 0 0 1h2.5v3.5a.5.5 0 0 0 1 0V9h2.5a.5.5 0 0 0 0-1H7V2a2 2 0 0 0-2-2z"/>
        </svg>
        <span style="font-size:12px;line-height:12px">Save</span>
      `;
      btn.addEventListener("click", () => this._onClickDownload());
      return btn;
    }

    _injectFloatingButton() {
      if (document.getElementById("sd-floating-btn")) return document.getElementById("sd-floating-btn");
      const floatBtn = document.createElement("button");
      floatBtn.id = "sd-floating-btn";
      floatBtn.title = "Download story";
      floatBtn.style.position = "fixed";
      floatBtn.style.right = "12px";
      floatBtn.style.top = "72px";
      floatBtn.style.zIndex = `${TOAST_ZINDEX}`;
      floatBtn.style.padding = "8px";
      floatBtn.style.borderRadius = "8px";
      floatBtn.style.background = "rgba(0,0,0,0.6)";
      floatBtn.style.color = "white";
      floatBtn.style.border = "none";
      floatBtn.style.cursor = "pointer";
      floatBtn.style.fontSize = "13px";
      floatBtn.style.boxShadow = "0 6px 18px rgba(0,0,0,0.3)";
      floatBtn.innerText = "⬇ Story";
      floatBtn.addEventListener("click", () => this._onClickDownload());
      document.body.appendChild(floatBtn);
      return floatBtn;
    }

    // -------------------------
    // Download flow
    // -------------------------
    async _onClickDownload() {
      this.showToast("Detecting story...");
      try {
        await this._detectMedia();
        if (!this.mediaUrl) throw new Error("No media detected");
        const filename = this._generateFileName();
        await this._downloadResource(this.mediaUrl, filename);
        this.showToast(`Saved: ${filename}`, 3000);
      } catch (err) {
        this.log("download error", err);
        this.showToast(`Download failed: ${err?.message || "unknown"}`, 4500);
      }
    }

    async _detectMedia() {
      this.mediaUrl = null;
      this.detectedVideo = false;

      // 1) direct video element scanning
      const directVideo = this._scanForVideoDirect();
      if (directVideo) {
        this.mediaUrl = directVideo;
        this.detectedVideo = true;
        this.log("detected video direct", directVideo);
        return;
      }

      // 2) meta tags (og:video)
      const metaV = this._readMeta(['og:video', 'og:video:url', 'og:video:secure_url']);
      if (metaV) {
        this.mediaUrl = metaV;
        this.detectedVideo = true;
        this.log("detected video meta", metaV);
        return;
      }

      // 3) direct image scanning
      const img = this._scanForImageDirect();
      if (img) {
        // img might be element or URL string
        this.mediaUrl = (typeof img === "string") ? img : img.src || this._getBackgroundUrl(img);
        this.detectedVideo = false;
        this.log("detected image direct", this.mediaUrl);
        return;
      }

      // 4) meta image (og:image)
      const metaImg = this._readMeta(['og:image', 'twitter:image']);
      if (metaImg) {
        this.mediaUrl = metaImg;
        this.detectedVideo = false;
        this.log("detected image meta", metaImg);
        return;
      }

      // 5) last resort: React fiber spelunk (fragile)
      const react = this._detectVideoViaReact();
      if (react) {
        this.mediaUrl = react;
        this.detectedVideo = true;
        this.log("detected video react fallback", react);
        return;
      }

      // nothing found
      this.log("no media detected");
    }

    _scanForVideoDirect() {
      try {
        // visible video elements
        const vids = Array.from(document.querySelectorAll("video")).filter(v => v instanceof HTMLVideoElement && v.offsetParent !== null && v.offsetHeight > 8 && v.offsetWidth > 8);
        for (const v of vids) {
          const src = v.currentSrc || v.src || (v.querySelector('source') && v.querySelector('source').src);
          if (src) return src;
        }
        // direct <source> tags anywhere
        const sources = Array.from(document.querySelectorAll("source")).map(s => s.src).filter(Boolean);
        if (sources.length) return sources[0];
      } catch (e) {
        this.log("video scan error", e);
      }
      return null;
    }

    _scanForImageDirect() {
      try {
        // Prefer visible images with a reasonable natural size
        const imgs = Array.from(document.querySelectorAll("img")).filter(img => {
          if (!(img instanceof HTMLImageElement)) return false;
          if (img.offsetParent === null) return false;
          try {
            return img.naturalWidth >= 150 && img.naturalHeight >= 150 && img.src && !img.src.startsWith("data:");
          } catch (e) {
            return false;
          }
        });
        // prefer CDN-looking urls
        const cdn = imgs.find(i => /cdn|fbcdn|instagram|akamai|cdninstagram/i.test(i.src));
        if (cdn) return cdn;
        if (imgs.length) return imgs[0];
        // fallback: background-image inline elements
        const elems = Array.from(document.querySelectorAll("div, span")).filter(e => {
          try { return e.offsetParent !== null && window.getComputedStyle(e).backgroundImage && window.getComputedStyle(e).backgroundImage !== "none"; } catch (e) { return false; }
        });
        if (elems.length) return elems[0];
      } catch (e) {
        this.log("image scan error", e);
      }
      return null;
    }

    _readMeta(names = []) {
      try {
        for (const key of names) {
          const el = document.querySelector(`meta[property="${key}"], meta[name="${key}"], meta[itemprop="${key}"]`);
          if (el && el.content) return el.content;
        }
      } catch (e) { /* ignore */ }
      return null;
    }

    _getBackgroundUrl(elem) {
      try {
        const bg = window.getComputedStyle(elem).backgroundImage;
        if (!bg || bg === "none") return null;
        const m = bg.match(/url\\((?:'|")?(.*?)(?:'|")?\\)/);
        return m ? m[1] : null;
      } catch (e) {
        return null;
      }
    }

    _detectVideoViaReact() {
      try {
        const videos = Array.from(document.querySelectorAll("video")).filter(v => v instanceof HTMLVideoElement);
        for (const video of videos) {
          const keys = Object.keys(video).filter(k => k.startsWith("__react") || k.startsWith("_react"));
          if (!keys.length) continue;
          for (const k of keys) {
            try {
              const fiber = video[k];
              // attempt to find videoData in nearby props
              const parent = video.parentElement?.parentElement?.parentElement?.parentElement;
              const reactKey = k.replace("__reactFiber", "");
              const props = parent?.[`__reactProps${reactKey}`] || parent?.props || fiber?.return?.stateNode?.props || {};
              const impl = props?.children?.[0]?.props?.children?.props?.implementations || props?.implementations || props?.videoData || {};
              if (Array.isArray(impl)) {
                for (const c of impl) {
                  const data = c?.data;
                  const url = data?.hdSrc || data?.sdSrc || data?.hd_src || data?.sd_src || data?.url;
                  if (url) return url;
                }
              } else {
                const url = impl?.hdSrc || impl?.sdSrc || impl?.hd_src || impl?.sd_src || impl?.url;
                if (url) return url;
              }
              const videoData = fiber?.return?.stateNode?.props?.videoData || {};
              const alt = videoData?.$1 || videoData;
              if (alt) {
                return alt.hd_src || alt.sd_src || alt.url || null;
              }
            } catch (e) {
              this.log("react fiber inner error", e);
            }
          }
        }
      } catch (e) {
        this.log("react fallback error", e);
      }
      return null;
    }

    // -------------------------
    // Filename & sanitize
    // -------------------------
    _generateFileName() {
      const date = new Date().toISOString().split("T")[0];
      let name = this._extractUserName() || (location.hostname.includes("facebook") ? "facebook-user" : "instagram-user");
      // sanitize to safe filename
      name = String(name).replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 64) || "user";
      const ext = this.detectedVideo ? "mp4" : "jpg";
      return `${name}-${date}.${ext}`;
    }

    _extractUserName() {
      try {
        // og:title often includes the user/page name
        const og = document.querySelector('meta[property="og:title"]')?.content;
        if (og) return og.split(" - ")[0].split("|")[0].trim();
        const canonical = document.querySelector('link[rel="canonical"]')?.href;
        if (canonical) {
          const parts = new URL(canonical).pathname.split("/").filter(Boolean);
          if (parts.length) return parts[parts.length - 1];
        }
        const usernameAnchor = Array.from(document.querySelectorAll('a[href*="/stories/"], a[href*="/"]')).find(a => {
          try {
            const h = a.getAttribute("href");
            return h && !h.includes("http") && h.split("/").length <= 3 && h !== "/";
          } catch (e) { return false; }
        });
        if (usernameAnchor) return usernameAnchor.pathname.replace(/\//g, "");
        // fallback: short text candidate
        const texts = Array.from(document.querySelectorAll("h1,h2,span,strong")).map(n => n.textContent?.trim()).filter(Boolean);
        if (texts.length) {
          const short = texts.sort((a, b) => a.length - b.length)[0];
          if (short && short.length < 64) return short;
        }
      } catch (e) { /* ignore */ }
      return null;
    }

    // -------------------------
    // Download helper
    // -------------------------
    async _downloadResource(url, filename) {
      if (!url) throw new Error("No url");
      // blob or data url: download directly
      try {
        if (url.startsWith("blob:") || url.startsWith("data:")) {
          this._triggerAnchorDownload(url, filename);
          return;
        }
      } catch (e) { /* ignore */ }

      // Try fetch first (may fail with CORS)
      try {
        const resp = await fetch(url, { credentials: "include" });
        if (!resp.ok) {
          throw new Error(`Network error: ${resp.status}`);
        }
        const blob = await resp.blob();
        const objectUrl = URL.createObjectURL(blob);
        try {
          this._triggerAnchorDownload(objectUrl, filename);
        } finally {
          setTimeout(() => URL.revokeObjectURL(objectUrl), 5000);
        }
        return;
      } catch (err) {
        // fetch could fail due to CORS or other network reasons. fallback to opening the url in a new tab.
        this.log("fetch failed, fallback to open", err);
        try {
          // open in new tab so user can right-click -> save as
          window.open(url, "_blank");
          this.showToast("Opened media in new tab. Right-click > Save as...", 4500);
          return;
        } catch (e) {
          throw new Error("Unable to fetch or open the resource.");
        }
      }
    }

    _triggerAnchorDownload(href, filename) {
      const a = document.createElement("a");
      a.href = href;
      a.download = filename || "";
      a.style.display = "none";
      document.body.appendChild(a);
      a.click();
      setTimeout(() => {
        try { a.remove(); } catch (e) {}
      }, 50);
    }
  }

  // initialize
  try {
    new StoryDownloader();
  } catch (e) {
    if (typeof console !== "undefined") console.error("StoryDownloader init failed", e);
  }
})();