FB Video Saver

Un script pour facebook.com permettant de télécharger des vidéos ou des collections de vidéos.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name        FB Video Saver
// @match       https://www.facebook.com/*
// @match       https://cobalt.tools/*
// @match       https://*.fbcdn.net/*
// @grant       GM_registerMenuCommand
// @grant       GM_setValue
// @grant       GM_getValue
// @grant       GM_download
// @version     2.0
// @author      Macxzew
// @description Un script pour facebook.com permettant de télécharger des vidéos ou des collections de vidéos.
// @license     MIT
// @namespace   https://greasyfork.org/users/1425005
// ==/UserScript==

(async () => {
  'use strict';

  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  const HOST = location.hostname;
  const KEY_PENDING_URL = 'fbVS_pending_url';
  const KEY_DONE_URLS = 'fbVS_done_urls';
  const KEY_DONE_FBCDN = 'fbVS_done_fbcdn';

  const isSavedPage = (url) =>
    url.includes('/saved/');

  const isVideoUrl = (url) =>
    url.includes('/watch/') ||
    url.includes('/reel/') ||
    url.includes('/videos/');

  const loadDoneList = async () => {
    const arr = await GM_getValue(KEY_DONE_URLS, []);
    return Array.isArray(arr) ? arr : [];
  };

  const saveDoneList = (arr) =>
    GM_setValue(KEY_DONE_URLS, arr);

  const addToDoneList = async (url) => {
    if (!url)
      return;
    const arr = await loadDoneList();
    if (!arr.includes(url)) {
      arr.push(url);
      await saveDoneList(arr);
      console.log('[FB Video Saver] URL ajoutée à DONE :', url);
    }
  };

  const createNotification = (message) => {
    const notification = document.createElement('div');
    notification.textContent = message;
    Object.assign(notification.style, {
      position: 'fixed',
      top: '20px',
      right: '20px',
      backgroundColor: 'rgba(0, 128, 0, 0.9)',
      color: 'white',
      padding: '10px 20px',
      borderRadius: '5px',
      boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
      fontSize: '14px',
      zIndex: '9999',
      animation: 'fadeout 3s forwards',
    });
    document.body.appendChild(notification);
    setTimeout(() => notification.remove(), 3000);

    const styleId = 'fb-video-saver-notif-style';
    if (!document.getElementById(styleId)) {
      const style = document.createElement('style');
      style.id = styleId;
      style.textContent = `
        @keyframes fadeout {
          0% { opacity: 1; }
          80% { opacity: 1; }
          100% { opacity: 0; }
        }
      `;
      document.head.appendChild(style);
    }
  };

  const extractVideoKey = (href) => {
    if (!href)
      return '';
    try {
      const u = new URL(href);
      const path = u.pathname;

      if (path.startsWith('/watch')) {
        const v = u.searchParams.get('v');
        if (v)
          return 'watch:' + v;
      }

      const mReel = path.match(/\/reel[s]?\/([^\/?&#]+)/);
      if (mReel)
        return 'reel:' + mReel[1];

      const mVid = path.match(/\/videos\/([^\/?&#]+)/);
      if (mVid)
        return 'videos:' + mVid[1];

      return 'url:' + u.origin + path;
    } catch (_) {
      const m = href.match(/\/(reel[s]?|videos)\/([^\/?&#]+)/);
      if (m)
        return m[1] + ':' + m[2];
      const vMatch = href.match(/[?&]v=([^&]+)/);
      if (vMatch)
        return 'watch:' + vMatch[1];
      return 'url:' + href.split('#')[0].split('?')[0];
    }
  };

  const sendToCobalt = async (urlToProcess) => {
    if (!urlToProcess)
      return false;
    try {
      await GM_setValue(KEY_PENDING_URL, urlToProcess);
      const w = window.open('https://cobalt.tools/', '_blank', 'noopener,noreferrer');
      if (!w)
        console.warn('[FB Video Saver] impossible d’ouvrir un onglet cobalt');
      console.log('[FB Video Saver] Cobalt ouvert pour :', urlToProcess);
      return true;
    } catch (e) {
      console.error('[FB Video Saver] sendToCobalt error', e);
      return false;
    }
  };

  const refreshPageContent = async () => {
    let lastHeight = document.body.scrollHeight;
    let timer;
    while (true) {
      window.scrollTo(0, document.body.scrollHeight);
      await new Promise(resolve => {
        clearTimeout(timer);
        timer = setTimeout(resolve, 5000);
      });
      const currentHeight = document.body.scrollHeight;
      if (currentHeight === lastHeight)
        break;
      lastHeight = currentHeight;
    }
  };

  const collectAllVideoLinks = (doneSet) => {
    const map = new Map();
    for (const a of document.querySelectorAll('a')) {
      const href = a.href;
      if (!href || !isVideoUrl(href))
        continue;
      if (doneSet.has(href)) {
        console.log('[FB Video Saver] Lien déjà dans DONE, ignoré :', href);
        continue;
      }
      const key = extractVideoKey(href);
      if (!key || map.has(key))
        continue;
      map.set(key, { href, key });
    }
    return Array.from(map.values());
  };

  const downloadVisibleVideo = async () => {
    const url = window.location.href;
    if (!isVideoUrl(url)) {
      alert('Aucune vidéo détectée sur cette page.');
      return false;
    }
    const ok = await sendToCobalt(url);
    if (ok)
      createNotification('Vidéo envoyée à Cobalt (onglet séparé).');
    return ok;
  };

  const processSavedVideos = async () => {
    const url = window.location.href;
    if (!isSavedPage(url))
      return;

    const doneArr = await loadDoneList();
    const doneSet = new Set(doneArr);
    let lastSentKey = null;
    const processedKeys = new Set();

    createNotification('Scan des vidéos enregistrées en cours...');
    await refreshPageContent();

    const links = collectAllVideoLinks(doneSet);
    console.log('[FB Video Saver] Liens uniques (par ID vidéo) à traiter :', links.length);
    if (!links.length) {
      createNotification('Aucune vidéo détectée sur cette page.');
      return;
    }

    let index = 0;

    const processNext = async () => {
      if (index >= links.length) {
        console.log('[FB Video Saver] File de liens terminée.');
        createNotification('Collection terminée, actualisation /saved...');
        setTimeout(() => window.location.reload(), 3000);
        return;
      }

      const { href: link, key } = links[index++];

      if (processedKeys.has(key)) {
        console.log('[FB Video Saver] ID vidéo déjà traité dans ce run, skip :', key, link);
        setTimeout(processNext, 0);
        return;
      }

      if (key === lastSentKey) {
        console.log('[FB Video Saver] Même ID vidéo que le précédent, skip :', key, link);
        setTimeout(processNext, 0);
        return;
      }

      processedKeys.add(key);
      console.log('[FB Video Saver] Envoi à Cobalt (queue 20s, anti-doublon ID) :', key, link);

      const success = await sendToCobalt(link);
      if (!success)
        console.warn('[FB Video Saver] Echec sendToCobalt pour :', link);

      lastSentKey = key;
      setTimeout(processNext, 20000);
    };

    processNext();
  };

  const addUIButton = () => {
    setTimeout(() => {
      if (document.getElementById('fb-video-saver-button'))
        return;

      const button = document.createElement('button');
      button.id = 'fb-video-saver-button';
      button.textContent = 'Télécharger';
      Object.assign(button.style, {
        position: 'fixed',
        top: '1%',
        right: '20%',
        backgroundColor: '#007bff',
        color: 'white',
        padding: '10px 20px',
        border: 'none',
        borderRadius: '5px',
        boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
        cursor: 'pointer',
        zIndex: '9999',
      });

      button.addEventListener('click', async () => {
        const url = window.location.href;
        if (isSavedPage(url)) {
          await GM_setValue(KEY_PENDING_URL, '');
          await GM_setValue(KEY_DONE_URLS, []);
          await GM_setValue(KEY_DONE_FBCDN, []);
          console.log('[FB Video Saver] Réinitialisation file, DONE fb et DONE fbcdn');
          await processSavedVideos();
        } else if (isVideoUrl(url)) {
          await downloadVisibleVideo();
        } else {
          alert('Aucune action disponible pour cette page.');
        }
      });

      document.body.appendChild(button);
    }, 2500);
  };

  const previousUrl = { value: window.location.href };

  const checkUrlChange = () => {
    const currentUrl = window.location.href;
    if (isSavedPage(currentUrl) && !isSavedPage(previousUrl.value)) {
      console.log('[FB Video Saver] Passage vers /saved, reload forcé...');
      window.location.reload();
    }
    previousUrl.value = currentUrl;
  };

  ((history) => {
    const originalPushState = history.pushState;
    const originalReplaceState = history.replaceState;
    history.pushState = function (...args) {
      const result = originalPushState.apply(this, args);
      window.dispatchEvent(new Event('pushstate'));
      return result;
    };
    history.replaceState = function (...args) {
      const result = originalReplaceState.apply(this, args);
      window.dispatchEvent(new Event('replacestate'));
      return result;
    };
  })(window.history);

  // fbcdn: auto-download + close, avec anti-doublon path (ignore les query)
  if (HOST.endsWith('.fbcdn.net')) {
    try {
      const rawUrl = window.location.href;
      let canon;
      try {
        const u = new URL(rawUrl);
        canon = u.origin + u.pathname;       // même fichier si même path, même si query diff
      } catch {
        canon = rawUrl.split('?')[0].split('#')[0];
      }

      const doneArr = await GM_getValue(KEY_DONE_FBCDN, []);
      if (doneArr.includes(canon)) {
        console.log('[FB Video Saver] fbcdn déjà téléchargé (canonique), skip :', canon);
      } else {
        doneArr.push(canon);
        await GM_setValue(KEY_DONE_FBCDN, doneArr);
        await GM_download({ url: rawUrl, name: 'video.mp4' });
        console.log('[FB Video Saver] Download video.mp4 depuis fbcdn :', canon);
      }
    } catch (e) {
      console.error('[FB Video Saver] GM_download error', e);
    }
    setTimeout(() => {
      try { window.close(); } catch (_) {}
    }, 1000);
    return;
  }

  // cobalt.tools: remplir le champ + marquer comme DONE + lancer download
  if (HOST === 'cobalt.tools') {
    const urlToProcess = await GM_getValue(KEY_PENDING_URL, '');
    if (!urlToProcess)
      return;
    await GM_setValue(KEY_PENDING_URL, '');

    const tryFill = () => {
      const input = document.querySelector('#link-area');
      if (!input)
        return false;
      input.value = urlToProcess;
      input.dispatchEvent(new Event('input', { bubbles: true }));
      input.dispatchEvent(new Event('change', { bubbles: true }));
      console.log('[FB Video Saver] URL collée dans #link-area');
      addToDoneList(urlToProcess).catch(() => {});
      return true;
    };

    let filled = false;
    for (let i = 0; i < 20; i++) {
      filled = tryFill();
      if (filled)
        break;
      await sleep(500);
    }
    if (!filled) {
      console.warn('[FB Video Saver] Impossible de remplir le champ sur Cobalt');
      return;
    }

    window.__fbVS_dlClicked = false;
    window.__fbVS_saveClicked = false;

    setTimeout(async () => {
      let clicked = false;
      for (let i = 0; i < 20 && !clicked; i++) {
        const btn =
          document.querySelector('button#download-button.svelte-1s9ornv') ||
          document.querySelector('#download-button');
        if (btn) {
          if (!window.__fbVS_dlClicked) {
            btn.click();
            window.__fbVS_dlClicked = true;
            console.log('[FB Video Saver] Clic sur #download-button (une seule fois)');
          }
          clicked = true;
          break;
        }
        await sleep(200);
      }
      if (!clicked)
        console.warn('[FB Video Saver] #download-button introuvable');

      setTimeout(async () => {
        let clickedSave = false;
        for (let j = 0; j < 30 && !clickedSave; j++) {
          const saveBtn =
            document.querySelector('button#button-save-download') ||
            document.querySelector('#button-save-download');

          if (saveBtn) {
            if (!window.__fbVS_saveClicked) {
              saveBtn.click();
              window.__fbVS_saveClicked = true;
              console.log('[FB Video Saver] Clic sur #button-save-download (1 fois)');
            }
            clickedSave = true;
            break;
          }
          await sleep(300);
        }
        if (!clickedSave)
          console.warn('[FB Video Saver] #button-save-download non trouvé (OK si non proposé)');
        setTimeout(() => {
          console.log('[FB Video Saver] Fermeture auto de Cobalt');
          try { window.close(); } catch (_) {}
        }, 5000);
      }, 4000);
    }, 2000);
    return;
  }

  if (!HOST.includes('facebook.com'))
    return;

  window.addEventListener('load', addUIButton);
  window.addEventListener('pushstate', checkUrlChange);
  window.addEventListener('replacestate', checkUrlChange);
  window.addEventListener('popstate', checkUrlChange);
  setInterval(checkUrlChange, 500);

  GM_registerMenuCommand('Download Video (via Cobalt, onglet séparé)', () => {
    const url = window.location.href;
    if (!isSavedPage(url) && !isVideoUrl(url)) {
      alert('Aucune action disponible pour cette page.');
    } else {
      downloadVisibleVideo();
    }
  });

  GM_registerMenuCommand('Process Saved Videos (via Cobalt, queue 20s)', processSavedVideos);
})();