Greasy Fork

Greasy Fork is available in English.

Story Downloader - Facebook and Instagram

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

当前为 2025-02-08 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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

(function () {
  "use strict";

  const SAFETY_DELAY = 2000;

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

    init() {
      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();
        setTimeout(() => this.createButton(), SAFETY_DELAY);
      } else if (btn) {
        btn.remove();
      }
    }

    injectGlobalStyles() {
      const style = document.createElement("style");

      style.textContent = `
      #downloadBtn {
        border: none;
        background: transparent;
        color: white;
        cursor: pointer;
        zIndex: 9999
      }
      `;

      document.head.appendChild(style);
    }

    createButton() {
      if (document.getElementById("downloadBtn")) return;

      const topBars = this.isFacebookPage
        ? Array.from(document.querySelectorAll("div.xtotuo0"))
        : Array.from(document.querySelectorAll("div.x1xmf6yo"));
      const topBar = topBars.find((bar) => bar.offsetHeight > 0);

      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 xmlns="http://www.w3.org/2000/svg" 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-2M8 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);
    }

    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) {
        console.log(error);
      }
    }

    async detectMedia() {
      return new Promise((resolve) => {
        const mediaDetector = () => {
          const video = this.findVideo();
          const image = this.findImage();

          if (video) {
            this.mediaUrl = video;
            resolve();
          } else if (image) {
            this.mediaUrl = image.src;
            resolve();
          }
        };
        mediaDetector();
      });
    }

    findVideo() {
      const videos = Array.from(document.querySelectorAll("video")).filter(
        (v) => v.offsetHeight > 0
      );

      if (videos.length !== 0) {
        for (const video of videos) {
          const videoUrl = this.searchVideoSource(video);
          if (videoUrl) {
            this.detectedVideo = true;
            return videoUrl;
          }
        }
      }

      return null;
    }

    searchVideoSource(video) {
      const reactFiberKey = Object.keys(video).find((key) =>
        key.startsWith("__reactFiber")
      );
      if (!reactFiberKey) return null;

      const reactKey = reactFiberKey.replace("__reactFiber", "");
      const parentElement =
        video.parentElement?.parentElement?.parentElement?.parentElement;
      const reactProps = parentElement?.[`__reactProps${reactKey}`];

      const implementations =
        reactProps?.children?.[0]?.props?.children?.props?.implementations ||
        reactProps?.children?.props?.children?.props?.implementations;

      let videoUrl = null;

      if (implementations) {
        for (const index of [1, 0, 2]) {
          const source = implementations[index]?.data;
          if (source) {
            videoUrl =
              source.hdSrc || source.sdSrc || source.hd_src || source.sd_src;
            if (videoUrl) break;
          }
        }
      }

      if (!videoUrl) {
        const videoData =
          video[reactFiberKey]?.return?.stateNode?.props?.videoData?.$1;
        videoUrl = videoData?.hd_src || videoData?.sd_src;
      }

      return videoUrl;
    }

    findImage() {
      const images = Array.from(document.querySelectorAll("img")).filter(
        (img) => img.offsetHeight > 0 && img.src.includes("cdn")
      );

      return images.find((img) => {
        const naturalSize = img.naturalWidth * img.naturalHeight;
        return naturalSize >= 500000;
      });
    }

    generateFileName() {
      const timestamp = new Date().toISOString().split("T")[0];
      const userNames = this.isFacebookPage
        ? Array.from(document.querySelectorAll("span")).filter(
            (e) => e.offsetWidth > 0 && e.offsetTop === -5
          )
        : Array.from(document.querySelectorAll(".x1i10hfl"));
      const userName = this.isFacebookPage
        ? userNames[userNames.length - 1].innerText || "uknown"
        : userNames
            .find((user) => user.offsetHeight > 0 && user.offsetHeight < 35)
            .pathname.replace(/\//g, "") || "uknown";
      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);
      }
    }
  }

  new StoryDownloader();
})();