FB Video Saver

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 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.1
// @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 lastScrollTop = window.scrollY;
        let lastActivityTime = Date.now();

        while (true) {
            // 1) activité utilisateur : changement de scroll (haut ou bas)
            const currentScrollTop = window.scrollY;
            if (currentScrollTop !== lastScrollTop) {
                lastScrollTop = currentScrollTop;
                lastActivityTime = Date.now(); // la page "bouge", on reset le timer
            }

            // 2) scroll auto en bas (pour forcer le chargement infini)
            window.scrollTo(0, document.body.scrollHeight);

            // 3) on attend un peu que le contenu ait le temps de se charger
            await sleep(500);

            // 4) activité contenu : la hauteur de la page change
            const currentHeight = document.body.scrollHeight;
            if (currentHeight !== lastHeight) {
                lastHeight = currentHeight;
                lastActivityTime = Date.now(); // nouveau contenu, on reset le timer
            }

            // 5) si aucune activité (ni scroll ni nouvelle hauteur) depuis > 10 s, on stop
            if (Date.now() - lastActivityTime > 10000) {
                break;
            }
        }
    };



    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)) {
                    // reset anti-doublon fbcdn pour permettre le re-download
                    await GM_setValue(KEY_DONE_FBCDN, []);
                    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é)', async () => {
        const url = window.location.href;
        if (!isSavedPage(url) && !isVideoUrl(url)) {
            alert('Aucune action disponible pour cette page.');
        } else {
            // reset anti-doublon fbcdn (download manuel)
            await GM_setValue(KEY_DONE_FBCDN, []);
            await downloadVisibleVideo();
        }
    });

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