Facebook Video & Story Downloader

Download any video on Facebook (post/chat/comment) and stories

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name          Facebook Video & Story Downloader
// @namespace     King1x32
// @icon          https://www.facebook.com/favicon.ico
// @match         https://www.facebook.com/*
// @exclude-match https://www.facebook.com/
// @version       2.1
// @description   Download any video on Facebook (post/chat/comment) and stories
// @license       MIT
// @grant         GM_registerMenuCommand
// @grant         GM_xmlhttpRequest
// @grant         GM_download
// ==/UserScript==

(() => {
  function getOverlapScore(el) {
    const rect = el.getBoundingClientRect();
    return (
      Math.min(rect.bottom, window.innerHeight || document.documentElement.clientHeight) -
      Math.max(0, rect.top)
    );
  }

  function getVideoIdFromVideoElement(video) {
    try {
      const src = video.src;
      if (src && src.includes('blob:')) {
        const wrapper = video.closest('[data-instancekey]');
        if (wrapper) {
          const instanceKey = wrapper.getAttribute('data-instancekey');
          if (instanceKey) {
            const match = instanceKey.match(/id-vpuid-([a-f0-9-]+)/);
            if (match) return match[1];
          }
        }
      }
    } catch (e) {}

    try {
      let currentElement = video;
      for (let i = 0; i < 10; i++) {
        if (!currentElement) break;
        for (let k in currentElement) {
          if (k.startsWith("__reactProps") || k.startsWith("__reactInternalInstance")) {
            try {
              const props = currentElement[k];
              if (props && props.children && props.children.props) {
                const videoFBID = props.children.props.videoFBID || props.children.props.videoId;
                if (videoFBID) return videoFBID;
              }
              if (props && props.videoFBID) return props.videoFBID;
              if (props && props.videoId) return props.videoId;
            } catch (e) {}
          }
        }
        currentElement = currentElement.parentElement;
      }
    } catch (e) {}

    try {
      const url = window.location.href;
      const videoIdMatch = url.match(/\/videos\/(\d+)/);
      if (videoIdMatch) return videoIdMatch[1];
      const watchMatch = url.match(/\/watch\/?\?.*[&?]v=(\d+)/);
      if (watchMatch) return watchMatch[1];
    } catch (e) {}

    try {
      let parent = video.parentElement;
      for (let i = 0; i < 20; i++) {
        if (!parent) break;
        const videoId = parent.getAttribute('data-video-id') ||
                       parent.getAttribute('data-video-fbid') ||
                       parent.getAttribute('data-videoid');
        if (videoId) return videoId;
        parent = parent.parentElement;
      }
    } catch (e) {}

    try {
      const scripts = document.querySelectorAll('script');
      for (const script of scripts) {
        if (script.textContent) {
          const match = script.textContent.match(/"videoFBID":"(\d+)"/);
          if (match) return match[1];
          const match2 = script.textContent.match(/"video_id":"(\d+)"/);
          if (match2) return match2[1];
        }
      }
    } catch (e) {}

    return null;
  }

  function findVideoIdInPageData() {
    try {
      if (window.__initialData) {
        const dataStr = JSON.stringify(window.__initialData);
        const match = dataStr.match(/"videoFBID":"(\d+)"/);
        if (match) return [match[1]];
      }

      if (window.require && typeof window.require.getData === 'function') {
        const data = window.require.getData();
        if (data) {
          const dataStr = JSON.stringify(data);
          const matches = dataStr.match(/"videoFBID":"(\d+)"/g) || [];
          return matches.map(m => m.match(/"videoFBID":"(\d+)"/)[1]);
        }
      }

      const pageHtml = document.documentElement.innerHTML;
      const matches = pageHtml.match(/"videoFBID":"(\d+)"/g) || [];
      const videoIds = matches.map(m => m.match(/"videoFBID":"(\d+)"/)[1]);
      if (videoIds.length > 0) return [...new Set(videoIds)];
    } catch (e) {}
    return [];
  }

  async function getWatchingVideoId() {
    const allVideos = Array.from(document.querySelectorAll("video"));
    const result = [];
    for (const video of allVideos) {
      const videoId = getVideoIdFromVideoElement(video);
      if (videoId) {
        result.push({
          videoId,
          overlapScore: getOverlapScore(video),
          playing:
            video.currentTime > 0 &&
            !video.paused &&
            !video.ended &&
            video.readyState > 2,
        });
      }
    }
    if (result.length === 0) {
      const pageVideoIds = findVideoIdInPageData();
      for (const videoId of pageVideoIds) {
        result.push({ videoId, overlapScore: 100, playing: false });
      }
    }
    const playingVideo = result.find((_) => _.playing);
    if (playingVideo) return [playingVideo.videoId];
    return result
      .filter((_) => _.videoId && (_.overlapScore > 0 || _.playing))
      .sort((a, b) => b.overlapScore - a.overlapScore)
      .map((_) => _.videoId);
  }

  async function getVideoUrlFromVideoId(videoId) {
    const dtsg = await getDtsg();
    try {
      return await getLinkFbVideo2(videoId, dtsg);
    } catch (e) {
      try {
        return await getLinkFbVideo1(videoId, dtsg);
      } catch (e2) {
        throw new Error(`Both download methods failed for video ${videoId}`);
      }
    }
  }

  async function getLinkFbVideo2(videoId, dtsg) {
    const res = await fetch(
      "https://www.facebook.com/video/video_data_async/?video_id=" + videoId,
      {
        method: "POST",
        headers: {
          "content-type": "application/x-www-form-urlencoded",
          "x-requested-with": "XMLHttpRequest",
        },
        body: stringifyVariables({
          __a: "1",
          fb_dtsg: dtsg,
        }),
      }
    );
    let text = await res.text();
    text = text.replace("for (;;);", "");
    const json = JSON.parse(text);
    const { hd_src, hd_src_no_ratelimit, sd_src, sd_src_no_ratelimit } =
      json?.payload || {};
    const videoUrl = hd_src_no_ratelimit || hd_src || sd_src_no_ratelimit || sd_src;
    if (!videoUrl) throw new Error('No video URL found in response');
    return videoUrl;
  }

  async function getLinkFbVideo1(videoId, dtsg) {
    const res = await fetchGraphQl(
      "5279476072161634",
      {
        UFI2CommentsProvider_commentsKey: "CometTahoeSidePaneQuery",
        caller: "CHANNEL_VIEW_FROM_PAGE_TIMELINE",
        videoID: videoId,
      },
      dtsg
    );
    const text = await res.text();
    const lines = text.split("\n");
    if (lines.length === 0) throw new Error('Empty response from GraphQL');
    const a = JSON.parse(lines[0]);
    if (!a.data || !a.data.video) throw new Error('No video data in GraphQL response');
    const videoUrl = a.data.video.playable_url_quality_hd || a.data.video.playable_url;
    if (!videoUrl) throw new Error('No playable URL found in video data');
    return videoUrl;
  }

  function fetchGraphQl(doc_id, variables, dtsg) {
    return fetch("https://www.facebook.com/api/graphql/", {
      method: "POST",
      headers: {
        "content-type": "application/x-www-form-urlencoded",
        "x-requested-with": "XMLHttpRequest",
      },
      body: stringifyVariables({
        doc_id: doc_id,
        variables: JSON.stringify(variables),
        fb_dtsg: dtsg,
        server_timestamps: true,
      }),
    });
  }

  function stringifyVariables(d, e) {
    const f = [];
    for (const a in d) {
      if (d.hasOwnProperty(a)) {
        const g = e ? e + "[" + a + "]" : a,
          b = d[a];
        f.push(
          b !== null && typeof b === "object"
            ? stringifyVariables(b, g)
            : encodeURIComponent(g) + "=" + encodeURIComponent(b)
        );
      }
    }
    return f.join("&");
  }

  async function getDtsg() {
    try {
      if (window.require) {
        return require("DTSGInitialData").token;
      }
    } catch (e) {}
    try {
      const dtsgMatch = document.documentElement.innerHTML.match(/"token":"([^"]+)"/);
      if (dtsgMatch) return dtsgMatch[1];
    } catch (e) {}
    try {
      const scripts = document.querySelectorAll('script');
      for (const script of scripts) {
        if (script.textContent && script.textContent.includes('DTSGInitialData')) {
          const match = script.textContent.match(/"token":"([^"]+)"/);
          if (match) return match[1];
        }
      }
    } catch (e) {}
    throw new Error('Could not find DTSG token');
  }

  async function downloadURL(url, name) {
    if (typeof GM_download !== 'undefined') {
      GM_download({
        url: url,
        name: name,
        saveAs: false,
        onload: () => console.log('Download completed: ' + name),
        onerror: (error) => {
          console.error('GM_download failed:', error);
          downloadUsingFetch(url, name);
        }
      });
      return;
    }

    downloadUsingFetch(url, name);
  }

  async function downloadUsingFetch(url, name) {
    try {
      if (typeof GM_xmlhttpRequest !== 'undefined') {
        GM_xmlhttpRequest({
          method: 'GET',
          url: url,
          responseType: 'blob',
          onload: function(response) {
            const blob = response.response;
            const blobUrl = URL.createObjectURL(blob);
            const link = document.createElement('a');
            link.href = blobUrl;
            link.download = name;
            link.style.display = 'none';
            document.body.appendChild(link);
            link.click();
            document.body.removeChild(link);
            setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
          },
          onerror: function(error) {
            console.error('Download failed:', error);
            window.open(url, '_blank');
          }
        });
      } else {
        const response = await fetch(url);
        const blob = await response.blob();
        const blobUrl = URL.createObjectURL(blob);
        const link = document.createElement('a');
        link.href = blobUrl;
        link.download = name;
        link.style.display = 'none';
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
        setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
      }
    } catch (error) {
      console.error('Download failed:', error);
      if (confirm('Direct download failed. Would you like to open the video in a new tab? You can right-click and save from there.')) {
        window.open(url, '_blank');
      }
    }
  }

  async function downloadWatchingVideo() {
    try {
      const listVideoId = await getWatchingVideoId();
      if (!listVideoId?.length) {
        throw Error("No video found on the page.");
      }
      let downloadCount = 0;
      for (const videoId of listVideoId) {
        try {
          const videoUrl = await getVideoUrlFromVideoId(videoId);
          if (videoUrl) {
            await downloadURL(videoUrl, `fb_video_${videoId}.mp4`);
            downloadCount++;
          }
        } catch (e) {}
      }
      if (downloadCount === 0) {
        throw new Error("Could not download any videos.");
      }
    } catch (e) {
      alert("ERROR: " + e.message);
    }
  }

  function createDownloadIcon(storySaver = null) {
    const icon = document.createElement('div');
    icon.className = 'fb-video-download-icon';
    icon.innerHTML = `
      <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
        <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
        <polyline points="7,10 12,15 17,10"></polyline>
        <line x1="12" y1="15" x2="12" y2="3"></line>
      </svg>
    `;
    icon.style.cssText = `
      position: absolute;
      top: 8px;
      right: 8px;
      width: 32px;
      height: 32px;
      display: flex;
      align-items: center;
      justify-content: center;
      background: rgba(0, 0, 0, 0.6);
      color: white;
      border-radius: 50%;
      cursor: pointer;
      transition: all 0.2s ease;
      z-index: 1001;
      backdrop-filter: blur(4px);
      opacity: 0.8;
    `;
    icon.addEventListener('mouseenter', () => {
      icon.style.opacity = '1';
      icon.style.background = 'rgba(0, 0, 0, 0.8)';
      icon.style.transform = 'scale(1.1)';
    });
    icon.addEventListener('mouseleave', () => {
      if (!icon.classList.contains('downloading')) {
        icon.style.opacity = '0.8';
        icon.style.background = 'rgba(0, 0, 0, 0.6)';
        icon.style.transform = 'scale(1)';
      }
    });
    icon.addEventListener('click', async (e) => {
      e.preventDefault();
      e.stopPropagation();
      if (icon.classList.contains('downloading')) return;
      icon.classList.add('downloading');
      icon.innerHTML = `
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="animation: spin 1s linear infinite;">
          <circle cx="12" cy="12" r="10"></circle>
        </svg>
      `;
      icon.style.background = 'rgba(66, 165, 245, 0.9)';
      icon.style.opacity = '1';
      try {
        if (/\/stories\//.test(window.location.href) && storySaver) {
          await storySaver.handleDownload();
        } else {
          await downloadWatchingVideo();
        }
        icon.innerHTML = `
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
            <path d="M20 6L9 17l-5-5"></path>
          </svg>
        `;
        icon.style.background = 'rgba(76, 175, 80, 0.9)';
        setTimeout(() => {
          icon.innerHTML = `
            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
              <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
              <polyline points="7,10 12,15 17,10"></polyline>
              <line x1="12" y1="15" x2="12" y2="3"></line>
            </svg>
          `;
          icon.style.background = 'rgba(0, 0, 0, 0.6)';
          icon.style.opacity = '0.8';
          icon.classList.remove('downloading');
        }, 2000);
      } catch (error) {
        icon.innerHTML = `
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
            <circle cx="12" cy="12" r="10"></circle>
            <line x1="15" y1="9" x2="9" y2="15"></line>
            <line x1="9" y1="9" x2="15" y2="15"></line>
          </svg>
        `;
        icon.style.background = 'rgba(244, 67, 54, 0.9)';
        setTimeout(() => {
          icon.innerHTML = `
            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
              <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
              <polyline points="7,10 12,15 17,10"></polyline>
              <line x1="12" y1="15" x2="12" y2="3"></line>
            </svg>
          `;
          icon.style.background = 'rgba(0, 0, 0, 0.6)';
          icon.style.opacity = '0.8';
          icon.classList.remove('downloading');
        }, 3000);
      }
    });
    return icon;
  }

  function shouldShowDownloadButton() {
    const url = window.location.href;
    if (/\/watch\?v=/.test(url)) return false;
    if (/facebook\.com\/stories\//.test(url)) return false;
    return true;
  }

  function addDownloadIconToVideo(videoElement) {
    if (!shouldShowDownloadButton()) return;
    if (videoElement.parentElement.querySelector('.fb-video-download-icon')) return;

    const videoContainer = videoElement.closest('[data-instancekey]') ||
                          videoElement.closest('[class*="x5yr21d"][class*="x1n2onr6"]') ||
                          videoElement.parentElement;
    if (videoContainer) {
      const containerStyle = window.getComputedStyle(videoContainer);
      if (containerStyle.position === 'static') {
        videoContainer.style.position = 'relative';
      }
      const icon = createDownloadIcon();
      videoContainer.appendChild(icon);
      videoContainer.addEventListener('mouseenter', () => {
        icon.style.opacity = '0.8';
      });
      videoContainer.addEventListener('mouseleave', () => {
        if (!icon.classList.contains('downloading')) {
          icon.style.opacity = '0.6';
        }
      });
      icon.style.opacity = '0.6';
    }
  }

  function observeForVideos() {
    const observer = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        mutation.addedNodes.forEach((node) => {
          if (node.nodeType === Node.ELEMENT_NODE) {
            const videos = node.querySelectorAll ? node.querySelectorAll('video') : [];
            if (node.tagName === 'VIDEO') {
              addDownloadIconToVideo(node);
            }
            videos.forEach(video => {
              setTimeout(() => addDownloadIconToVideo(video), 300);
            });
          }
        });
      });
    });
    observer.observe(document.body, { childList: true, subtree: true });
    return observer;
  }

  class StorySaver {
    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.createButtonWithPolling();
      } else if (btn) {
        btn.remove();
      }
    }

    createButtonWithPolling() {
      let attempts = 0;
      const interval = setInterval(() => {
        if (document.getElementById("downloadBtn")) {
          clearInterval(interval);
          return;
        }
        const createdBtn = this.createButton();
        if (createdBtn || attempts >= 5) clearInterval(interval);
        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) return null;

      const btn = document.createElement("button");
      btn.id = "downloadBtn";
      btn.textContent = "⬇";
      btn.style.fontSize = "20px";
      btn.style.background = "transparent";
      btn.style.border = "none";
      btn.style.color = "white";
      btn.style.cursor = "pointer";
      btn.style.zIndex = "9999";

      btn.addEventListener("click", () => this.handleDownload());
      topBar.appendChild(btn);
      return btn;
    }

    async handleDownload() {
      try {
        await this.detectMedia();
        if (!this.mediaUrl) return;
        const filename = this.generateFileName();
        await this.downloadMedia(this.mediaUrl, filename);
      } catch {}
    }

    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;
      }
    }

    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 = 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 {
        // Use the same improved download method
        await downloadURL(url, filename);
      } catch (error) {
        console.error('Story download failed:', error);
      }
    }
  }

  const style = document.createElement('style');
  style.textContent = `
    @keyframes spin {
      from { transform: rotate(0deg); }
      to { transform: rotate(360deg); }
    }
  `;
  document.head.appendChild(style);

  setTimeout(() => {
    const existingVideos = document.querySelectorAll('video');
    existingVideos.forEach(video => {
      addDownloadIconToVideo(video);
    });
  }, 1000);

  observeForVideos();

  if (/\/stories\//.test(window.location.href)) {
    new StorySaver();
  }

  GM_registerMenuCommand("Download Video", downloadWatchingVideo);

  document.addEventListener('keydown', function(e) {
    if (e.ctrlKey && e.shiftKey && e.key === 'D') {
      e.preventDefault();
      downloadWatchingVideo();
    }
  });
})();