Story Downloader - Facebook and Instagram

Download stories (videos and images) from Facebook and Instagram.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Story Downloader - Facebook and Instagram
// @namespace    https://github.com/oscar370
// @version      2.0.6
// @description  Download stories (videos and images) from Facebook and Instagram.
// @author       oscar370
// @match        *.facebook.com/*
// @match        *.instagram.com/*
// @grant        none
// @license      GPL3
// ==/UserScript==

"use strict";
(() => {
  // src/main.ts
  (function() {
    "use strict";
    const MAX_ATTEMPTS = 10;
    const isDev = false;
    class StoryDownloader {
      constructor() {
        this.mediaUrl = null;
        this.detectedVideo = null;
        this.init();
      }
      init() {
        this.log("Initializing observer...");
        this.setupMutationObserver();
      }
      setupMutationObserver() {
        const observer = new MutationObserver(() => {
          this.checkPageStructure();
        });
        observer.observe(document.body, { childList: true, subtree: true });
      }
      get isFacebookPage() {
        return /(facebook)/.test(window.location.href);
      }
      checkPageStructure() {
        const btn = document.getElementById("downloadBtn");
        if (/(\/stories\/)/.test(window.location.href)) {
          this.injectGlobalStyles();
          this.createButtonWithPolling();
        } else if (btn) {
          btn.remove();
        }
      }
      injectGlobalStyles() {
        if (document.getElementById("downloadBtnStyles")) return;
        const style = document.createElement("style");
        style.id = "#downloadBtnStyles";
        style.textContent = `
        #downloadBtn {
          border: none;
          background: transparent;
          color: white;
          cursor: pointer;
          z-index: 9999;
        }
      `;
        document.head.appendChild(style);
      }
      createButtonWithPolling() {
        let attempts = 0;
        const interval = setInterval(() => {
          const existingBtn = document.getElementById("downloadBtn");
          if (existingBtn) {
            clearInterval(interval);
            this.log("Button already present", existingBtn);
            return;
          }
          const createdBtn = this.createButton();
          if (createdBtn) {
            clearInterval(interval);
            this.log("Button successfully created", createdBtn);
            return;
          }
          attempts++;
          if (attempts >= MAX_ATTEMPTS) {
            clearInterval(interval);
            this.log("Button creation failed after max attempts");
          }
        }, 500);
      }
      createButton() {
        if (document.getElementById("downloadBtn")) return null;
        const topBars = this.isFacebookPage ? Array.from(document.querySelectorAll("div.xtotuo0")) : Array.from(document.querySelectorAll("div.x1xmf6yo"));
        const topBar = topBars.find(
          (bar) => bar instanceof HTMLElement && bar.offsetHeight > 0
        );
        if (!topBar) {
          this.log("No suitable top bar found");
          return null;
        }
        const btn = document.createElement("button");
        btn.id = "downloadBtn";
        btn.innerHTML = `
    <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor"
         class="bi bi-file-arrow-down-fill" viewBox="0 0 16 16">
      <path d="M12 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2
               M8 5a.5.5 0 0 1 .5.5v3.793l1.146-1.147a.5.5 0 0 1 
               .708.708l-2 2a.5.5 0 0 1-.708 0l-2-2a.5.5 0 1 1 
               .708-.708L7.5 9.293V5.5A.5.5 0 0 1 8 5"/>
    </svg>
  `;
        btn.addEventListener("click", () => this.handleDownload());
        topBar.appendChild(btn);
        this.log("Download button added", btn);
        return btn;
      }
      async handleDownload() {
        try {
          await this.detectMedia();
          if (!this.mediaUrl) throw new Error("No multimedia content was found");
          const filename = this.generateFileName();
          await this.downloadMedia(this.mediaUrl, filename);
        } catch (error) {
          this.log("Download failed:", error);
        }
      }
      async detectMedia() {
        const video = this.findVideo();
        const image = this.findImage();
        if (video) {
          this.mediaUrl = video;
          this.detectedVideo = true;
        } else if (image) {
          this.mediaUrl = image.src;
          this.detectedVideo = false;
        }
        this.log("Media URL detected:", this.mediaUrl);
      }
      findVideo() {
        const videos = Array.from(document.querySelectorAll("video")).filter(
          (v) => v.offsetHeight > 0
        );
        for (const video of videos) {
          const url = this.searchVideoSource(video);
          if (url) {
            return url;
          }
        }
        return null;
      }
      searchVideoSource(video) {
        const reactFiberKey = Object.keys(video).find(
          (key) => key.startsWith("__reactFiber")
        );
        if (!reactFiberKey) return null;
        const reactKey = reactFiberKey.replace("__reactFiber", "");
        const parent = video.parentElement?.parentElement?.parentElement?.parentElement;
        const reactProps = parent?.[`__reactProps${reactKey}`];
        const implementations = reactProps?.children?.[0]?.props?.children?.props?.implementations ?? reactProps?.children?.props?.children?.props?.implementations;
        if (implementations) {
          for (const index of [1, 0, 2]) {
            const source = implementations[index]?.data;
            const url = source?.hdSrc || source?.sdSrc || source?.hd_src || source?.sd_src;
            if (url) return url;
          }
        }
        const videoData = video[reactFiberKey]?.return?.stateNode?.props?.videoData?.$1;
        return videoData?.hd_src || videoData?.sd_src || null;
      }
      findImage() {
        const images = Array.from(document.querySelectorAll("img")).filter(
          (img) => img.offsetHeight > 0 && img.src.includes("cdn")
        );
        return images.find((img) => img.height > 400) || null;
      }
      generateFileName() {
        const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
        let userName = "unknown";
        if (this.isFacebookPage) {
          const user = Array.from(
            document.querySelectorAll("span.xuxw1ft.xlyipyv")
          ).find(
            (e) => e instanceof HTMLElement && e.offsetWidth > 0
          );
          userName = user?.innerText || userName;
        } else {
          const user = Array.from(document.querySelectorAll(".x1i10hfl")).find(
            (u) => u instanceof HTMLAnchorElement && u.offsetHeight > 0 && u.offsetHeight < 35
          );
          userName = user?.pathname.replace(/\//g, "") || userName;
        }
        const extension = this.detectedVideo ? "mp4" : "jpg";
        return `${userName}-${timestamp}.${extension}`;
      }
      async downloadMedia(url, filename) {
        try {
          const response = await fetch(url);
          const blob = await response.blob();
          const link = document.createElement("a");
          link.href = URL.createObjectURL(blob);
          link.download = filename;
          document.body.appendChild(link);
          link.click();
          document.body.removeChild(link);
          URL.revokeObjectURL(link.href);
        } catch (error) {
          console.error("Download error:", error);
        }
      }
      log(...args) {
        if (isDev) console.log("[StoryDownloader]", ...args);
      }
    }
    new StoryDownloader();
  })();
})();