Facebook Video & Story Downloader

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

// ==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();
    }
  });
})();