Story Downloader - Facebook & Instagram (Desktop — Icon Fallback)

Download stories (videos/images) from facebook.com and instagram.com (desktop). Small icon fallback top-right if UI insertion fails. Dev logs ON for testing.

// ==UserScript==
// @name         Story Downloader - Facebook & Instagram (Desktop — Icon Fallback)
// @namespace    https://github.com/oscar370
// @version      2.2.0
// @description  Download stories (videos/images) from facebook.com and instagram.com (desktop). Small icon fallback top-right if UI insertion fails. Dev logs ON for testing.
// @author       patched
// @match        *://*.facebook.com/*
// @match        *://*.instagram.com/*
// @grant        none
// @license      GPL3
// ==/UserScript==

(() => {
  "use strict";

  // CONFIG
  const POLL_INTERVAL_MS = 300;
  const MAX_POLL_ATTEMPTS = 120; // ~36s before guaranteed fallback shown (but fallback will also show earlier if needed)
  const TOAST_ZINDEX = 2147483647;
  const isDev = true; // set to false after testing

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

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

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

    // -------------------------
    // Page checks
    // -------------------------
    checkPageStructure() {
      const onStory = this._isStoryUrl() || this._looksLikeStoryModal();
      this.log("checkPageStructure - onStory:", onStory);
      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 {
        const dialog = document.querySelector('div[role="dialog"], [role="presentation"]');
        if (dialog && dialog.querySelector("video, img, [style*='background-image']")) return true;
        // FB-specific hints
        if (document.querySelector('[data-pagelet*="Story"], [aria-label*="Story"]')) return true;
      } catch (e) { /* ignore */ }
      return false;
    }

    // -------------------------
    // Styles & small icon button
    // -------------------------
    injectStyles() {
      if (document.getElementById("sd-styles")) return;
      const style = document.createElement("style");
      style.id = "sd-styles";
      style.textContent = `
        #sd-download-icon {
          position: fixed;
          top: 10px;
          right: 10px;
          width: 40px;
          height: 40px;
          background: rgba(0,0,0,0.6);
          color: white;
          border-radius: 8px;
          display: inline-flex;
          align-items: center;
          justify-content: center;
          z-index: ${TOAST_ZINDEX};
          cursor: pointer;
          box-shadow: 0 6px 18px rgba(0,0,0,0.35);
          transition: transform .12s ease;
        }
        #sd-download-icon:hover { transform: translateY(-2px); }
        #downloadBtn { border: none; background: transparent; color: inherit; cursor: pointer; }
        #sd-toast {
          position: fixed;
          right: 12px;
          top: 60px;
          z-index: ${TOAST_ZINDEX};
          background: rgba(0,0,0,0.85);
          color: #fff;
          padding: 8px 12px;
          border-radius: 6px;
          font-size: 13px;
          max-width: 360px;
          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-download-icon"); if (f) f.remove();
        const t = document.getElementById("sd-toast"); if (t) t.remove();
      } catch (e) { /* ignore */ }
    }

    // -------------------------
    // Poll for UI & insert button
    // -------------------------
    startPollingForButton() {
      if (this._poller) return;
      this._attempts = 0;
      this._poller = setInterval(() => {
        this._attempts++;
        this.log("poll attempt", this._attempts);

        // if story no longer present stop
        if (!this._isStoryUrl() && !this._looksLikeStoryModal()) {
          this.stopPollingForButton();
          return;
        }

        // if already injected, skip
        if (document.getElementById("downloadBtn") || document.getElementById("sd-download-icon")) {
          // keep it visible if present
          if (this._attempts === 1) this.log("button already present");
          return;
        }

        // try insertion targets
        const tryInsert = this._insertIntoTopBar() || this._insertIntoDialog() || null;
        if (tryInsert) {
          this.log("Inserted into page UI", tryInsert);
          return;
        }

        // after some attempts, ensure fallback icon exists
        if (this._attempts >= 6) {
          this.log("creating guaranteed fallback icon");
          this._createFallbackIcon(); // idempotent
        }

        // if beyond max attempts, keep fallback, but keep polling in case topbar appears.
        if (this._attempts >= MAX_POLL_ATTEMPTS) {
          this.log("max attempts reached — keeping fallback icon");
        }
      }, POLL_INTERVAL_MS);
    }

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

    _createFallbackIcon() {
      if (document.getElementById("sd-download-icon")) return document.getElementById("sd-download-icon");
      const icon = document.createElement("div");
      icon.id = "sd-download-icon";
      icon.title = "Download story";
      icon.innerHTML = `
        <button id="downloadBtn" aria-label="Download story" style="all:unset;display:inline-flex;align-items:center;justify-content:center;">
          <svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
            <path d="M.5 9.9v3.6A1 1 0 0 0 1.5 15h13a1 1 0 0 0 1-1.5V9.9a.5.5 0 0 0-1 0v3.6a.001.001 0 0 1-.001.001H1.5a.001.001 0 0 1-.001-.001V9.9a.5.5 0 0 0-1 0zM8 0a.5.5 0 0 0-.5.5V9H5.354a.5.5 0 0 0-.354.854l2.647 2.646a.5.5 0 0 0 .707 0L10.999 9.854a.5.5 0 0 0-.354-.854H8V.5A.5.5 0 0 0 8 0z"/>
          </svg>
        </button>
      `;
      // click handled by the inner button
      icon.querySelector("#downloadBtn").addEventListener("click", () => this._onClickDownload());
      document.body.appendChild(icon);
      return icon;
    }

    _insertIntoTopBar() {
      const selectors = [
        'header[role="banner"]',
        '[role="toolbar"]',
        '[data-testid="story-header"]',
        'div[aria-label*="Story"]',
        'div[style*="position: sticky"][style*="top"]',
        'div[style*="position: fixed"][style*="top"]'
      ];
      for (const sel of selectors) {
        try {
          const nodes = Array.from(document.querySelectorAll(sel)).filter(n => n instanceof HTMLElement && n.offsetParent !== null);
          if (!nodes.length) continue;
          for (const node of nodes) {
            if (node.querySelector("#downloadBtn")) return node.querySelector("#downloadBtn");
            try {
              const btn = this._createPageButton();
              node.appendChild(btn);
              return btn;
            } catch (e) {
              this.log("append failed for selector", sel, e);
              try {
                const btn = this._createPageButton();
                node.insertBefore(btn, node.firstChild);
                return btn;
              } catch (e2) {
                this.log("insertBefore failed", e2);
                continue;
              }
            }
          }
        } catch (e) { this.log("selector error", sel, e); }
      }
      return null;
    }

    _insertIntoDialog() {
      const dialog = document.querySelector('div[role="dialog"], [role="presentation"]');
      if (!dialog || !(dialog instanceof HTMLElement) || dialog.offsetParent === null) return null;
      // try to find a top-right or toolbar inside the dialog
      const candidate = dialog.querySelector('[role="toolbar"], header, div[style*="position: absolute"], div[style*="position: fixed"]');
      if (candidate && candidate instanceof HTMLElement && candidate.offsetParent !== null) {
        if (candidate.querySelector("#downloadBtn")) return candidate.querySelector("#downloadBtn");
        try {
          const btn = this._createPageButton();
          candidate.appendChild(btn);
          return btn;
        } catch (e) {
          this.log("dialog append failed", e);
          try {
            const btn = this._createPageButton();
            candidate.insertBefore(btn, candidate.firstChild);
            return btn;
          } catch (e2) { this.log("dialog insert failed", e2); }
        }
      }
      return null;
    }

    _createPageButton() {
      if (document.getElementById("downloadBtn")) return document.getElementById("downloadBtn");
      const btn = document.createElement("button");
      btn.id = "downloadBtn";
      btn.title = "Download story";
      btn.style.all = "unset";
      btn.style.cursor = "pointer";
      btn.style.display = "inline-flex";
      btn.style.alignItems = "center";
      btn.style.gap = "6px";
      btn.innerHTML = `
        <svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
          <path d="M8 0a.5.5 0 0 0-.5.5V8H5.354a.5.5 0 0 0-.354.854l2.647 2.646a.5.5 0 0 0 .707 0L10.999 8.854A.5.5 0 0 0 10.646 8H8V.5A.5.5 0 0 0 8 0z"/>
        </svg>
      `;
      btn.addEventListener("click", () => this._onClickDownload());
      return btn;
    }

    // -------------------------
    // 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}`, 3200);
      } 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 elements
      const v = this._scanForVideoDirect();
      if (v) { this.mediaUrl = v; this.detectedVideo = true; this.log("video direct", v); return; }

      // 2) meta 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("video meta", metaV); return; }

      // 3) images
      const imgElemOrUrl = this._scanForImageDirect();
      if (imgElemOrUrl) {
        this.mediaUrl = typeof imgElemOrUrl === "string" ? imgElemOrUrl : (imgElemOrUrl.src || this._getBackgroundUrl(imgElemOrUrl));
        this.detectedVideo = false;
        this.log("image direct", this.mediaUrl);
        return;
      }

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

      // 5) React internal fallback
      const react = this._detectVideoViaReact();
      if (react) { this.mediaUrl = react; this.detectedVideo = true; this.log("react fallback", react); return; }

      this.log("no media after all strategies");
    }

    _scanForVideoDirect() {
      try {
        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;
        }
        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 {
        const imgs = Array.from(document.querySelectorAll("img")).filter(img => img instanceof HTMLImageElement && img.offsetParent !== null && img.naturalWidth >= 120 && img.naturalHeight >= 120 && img.src && !img.src.startsWith("data:"));
        if (imgs.length) {
          const cdn = imgs.find(i => /cdn|fbcdn|instagram|akama|akamai|cdninstagram/i.test(i.src));
          return cdn || imgs[0];
        }
        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(el) {
      try {
        if (!el) return null;
        const bg = window.getComputedStyle(el).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];
              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 url = c?.data?.hdSrc || c?.data?.sdSrc || c?.data?.hd_src || c?.data?.sd_src || c?.data?.url || c?.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 inner error", e); }
          }
        }
      } catch (e) { this.log("react fallback error", e); }
      return null;
    }

    // -------------------------
    // File naming & sanitize
    // -------------------------
    _generateFileName() {
      const date = new Date().toISOString().split("T")[0];
      let name = this._extractUserName() || (location.hostname.includes("facebook") ? "facebook-user" : "instagram-user");
      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 {
        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 anchor = 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 (anchor) return anchor.pathname.replace(/\//g, "");
        const texts = Array.from(document.querySelectorAll("h1,h2,span,strong")).map(n => n.textContent?.trim()).filter(Boolean);
        if (texts.length) return texts.sort((a,b)=>a.length-b.length)[0];
      } catch (e) { /* ignore */ }
      return null;
    }

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

      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) {
        this.log("fetch failed; opening in new tab as fallback", err);
        try {
          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) {} }, 80);
    }
  }

  // start
  try {
    new StoryDownloader();
    console.log("[SD] StoryDownloader injected (dev logs ON).");
  } catch (e) {
    console.error("[SD] init failed", e);
  }
})();