抖音归档

easy way to archive tiktok or douyin's video

// ==UserScript==
// @name         抖音归档
// @namespace    douyin_archive
// @version      1.1
// @description  easy way to archive tiktok or douyin's video
// @author       邪不压正
// @license      AGPL License
// @run-at       document-start
// @match        https://www.douyin.com/user/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=douyin.com
// @grant        unsafeWindow
// @grant        GM_openInTab
// @grant        GM_xmlhttpRequest
// ==/UserScript==

(function () {
    'use strict';

    //put your code here ?
    const realSend = XMLHttpRequest.prototype.send;
    logme(" replaced")
    XMLHttpRequest.prototype.send = function () {

        const xhr = this;
        //注册监听是慢的,直接替换是快的
        this.onreadystatechange = function () {
            if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
                // 请求已完成且状态码为200
                const url = xhr.responseURL;
                if (url.includes('aweme/v1/web/aweme/post/')) {
                    const response = JSON.parse(xhr.responseText)
                    const awemeList = response.aweme_list

                    awemeList.forEach(item => {
                        if (item.media_type === 4) {
                            mergedVideoList.push(item);
                        } else if (item.media_type === 2) {
                            mergedPicsList.push(item);
                            // item.images.forEach((image, index) => {
                            //     const url = image.url_list[0];
                            //     mergedPicListAlone.push(url)
                            // });
                        }
                    })
                    mergedList = mergedList.concat(awemeList)
                    hasMore = response.has_more
                    logme("-------- catch!--------" + awemeList.length + awemeList[0].desc)
                    updateTotalCount()
                    setTimeout(() => {
                        updateVideoBannerList()
                    }, 200)

                    // audioUrl = awemeList[0].music.play_url.uri
                }
            }
        }
        realSend.apply(this, arguments);
    };

    logme("---------------------------archive---------------------------")

    let mergedVideoList = []
    let mergedList = []
    let mergedPicsList = []
    let mergedPicListAlone = []
    let hasMore = 1

    //去水印下载
    if (window.location.host !== "www.douyin.com") {
        return;
    }

    window.addEventListener('load', function () {
        logme("Window onLOAD")
        if (!window.location.href.match(/https:\/\/www\.douyin\.com\/user\/.*?/))
            return
        logme("into page")

        async function downloader() {
            try {

                logme("into await allDownBtn")
                //延迟加载等到第一次视频加载完成
                let videoTop = await awaitQuery(".sDAMysaM")
                videoTop.style.height = '88px'
                //附加下载按钮
                let downText = document.createElement('div')
                downText.innerText = '已加载';
                downText.style.display = 'inline-block';
                downText.style.whiteSpace = 'nowrap';
                downText.style.textAlign = 'center'
                downText.style.fontSize = '13px'
                downText.style.color = 'white'
                downText.id = "downText"
                videoTop.append(downText)

                let picsDownBtn = document.createElement('button')
                picsDownBtn.innerText = '图文'
                picsDownBtn.classList.add('B10aL8VQ')
                picsDownBtn.classList.add('s6mStVxD')
                picsDownBtn.classList.add('vMQD6aai')
                picsDownBtn.classList.add('vk7WaOg_')
                picsDownBtn.classList.add('a2I1sBCL')
                picsDownBtn.classList.add('tAofAbwG')
                picsDownBtn.style.marginLeft = '10px'
                videoTop.append(picsDownBtn)
                let videoDownBtn = document.createElement('button')
                videoDownBtn.innerText = '视频'
                videoDownBtn.classList.add('B10aL8VQ')
                videoDownBtn.classList.add('s6mStVxD')
                videoDownBtn.classList.add('vMQD6aai')
                videoDownBtn.classList.add('vk7WaOg_')
                videoDownBtn.classList.add('a2I1sBCL')
                videoDownBtn.classList.add('tAofAbwG')
                videoDownBtn.style.marginLeft = '10px'
                videoTop.append(videoDownBtn)


                let allDownBtn = document.createElement('button')
                allDownBtn.innerText = '全部'
                allDownBtn.classList.add('B10aL8VQ')
                allDownBtn.classList.add('s6mStVxD')
                allDownBtn.classList.add('vMQD6aai')
                allDownBtn.classList.add('vk7WaOg_')
                allDownBtn.classList.add('a2I1sBCL')
                allDownBtn.classList.add('tAofAbwG')
                allDownBtn.style.marginLeft = '10px'
                videoTop.append(allDownBtn)

                updateTotalCount()


                picsDownBtn.addEventListener("click", async (e) => {
                    for (const pics of mergedPicsList) {
                        await downloadPics(pics);
                        const awemeId = pics.aweme_id;
                        localStorage.setItem('downloaded_' + awemeId, true);
                    }
                });

                videoDownBtn.addEventListener("click",  async (e) => {
                    for (const video of mergedVideoList) {
                        const name = generateVideoFilename(video); // 生成视频的文件名
                        await downloadVideo(convertHttpToHttps(getVideoUrl(video)), name); // 调用下载函数下载视频
                        // 给每个视频设置下载标记
                        const awemeId = video.aweme_id;
                        localStorage.setItem('downloaded_' + awemeId, true);
                    }
                });

                allDownBtn.addEventListener("click", (e) => {
                    alert("懒得写了 分别下吧 ")
                });

            } catch (e) {
            }
        }

        downloader().then(r => {
        });
        // window.addEventListener("click", downloader);
    });

    //其他方法
    function awaitQuery(selectors, delay = 200) {
        return new Promise(resolve => {
            let totalDelay = 0;
            let elementInterval = setInterval(() => {
                if (totalDelay >= 2500) {
                    clearInterval(elementInterval);
                }
                let element = document.querySelector(selectors);
                if (element) {
                    resolve(element);
                    clearInterval(elementInterval);
                } else {
                    totalDelay += delay;
                }
            }, delay);
        })
    }

    async function downloadVideo(url, name) {
        try {
            const response = await fetch(url);
            if (!response.ok) {
                throw new Error(`Download failed for ${url}.`);
            }
            const blob = await response.blob();
            const a = document.createElement("a");
            const objectUrl = window.URL.createObjectURL(blob);
            a.download = name;
            a.href = objectUrl;
            a.click();
            window.URL.revokeObjectURL(objectUrl);
            a.remove();
        } catch (error) {
            console.error(error);
        }
    }

    async function downloadPics(imageInfo) {
        for (let i = 0; i < imageInfo.images.length; i++) {
            const image = imageInfo.images[i];
            const url = image.url_list[0];
            const numberedName = `${generatePicsFilename(imageInfo)}_${i + 1}`; // 添加编号
            await downloadImage(url, numberedName);
        }
    }

    async function downloadImage(url, name) {
        try {
            const response = await fetch(url);
            if (!response.ok) {
                throw new Error(`Download failed for ${url}.`);
            }
            const blob = await response.blob();
            const a = document.createElement("a");
            const objectUrl = window.URL.createObjectURL(blob);
            a.download = name;
            a.href = objectUrl;
            a.click();
            window.URL.revokeObjectURL(objectUrl);
            a.remove();
        } catch (error) {
            console.error(error);
        }
    }

    function convertHttpToHttps(url) {
        if (url.startsWith('http://')) {
            return 'https://' + url.substring('http://'.length);
        }
        return url;
    }

    function updateTotalCount() {
        const loadedText = document.getElementById("downText");
        if (loadedText) {
            let loadedVideoCount = mergedVideoList.length.toString()
            logme(" loadedVideoCount  " + loadedVideoCount)
            if (hasMore === 1) {
                loadedText.textContent = `已加载 图文:${mergedPicsList.length}个 视频${loadedVideoCount}/${mergedVideoList[0].author.aweme_count}个 还有视频未加载`;
            } else if (loadedVideoCount < mergedVideoList[0].author.aweme_count) {
                loadedText.textContent = `已加载 图文:${mergedPicsList.length}个 视频${loadedVideoCount}/${mergedVideoList[0].author.aweme_count}个 误差大是加载时序紊乱,请刷新 / 误差小是有视频被隐藏`;
                const refresh = document.createElement('button');
                refresh.innerText = "刷新"
                refresh.style.marginLeft = '8px'
                refresh.classList = "B10aL8VQ s6mStVxD vMQD6aai vk7WaOg_ a2I1sBCL tAofAbwG"
                refresh.addEventListener("click", () => {
                    location.reload()
                })
                loadedText.append(refresh)
            } else {
                loadedText.textContent = `已加载 图文:${mergedPicsList.length}个 视频${loadedVideoCount}/${mergedVideoList[0].author.aweme_count}个 全部加载完成`;
            }
        }
    }

    function formatTimestamp(timestamp) {
        const date = new Date(timestamp * 1000); // 将秒转换为毫秒
        const year = date.getFullYear();
        const month = String(date.getMonth() + 1).padStart(2, '0');
        const day = String(date.getDate()).padStart(2, '0');
        const hours = String(date.getHours()).padStart(2, '0');
        return `${year}-${month}-${day}-${hours}`;
    }

    function updateVideoBannerList() {
        let list = document.querySelector('.UFuuTZ1P')
        let ul = list.querySelector("ul");
        let liList = ul.querySelectorAll("li");

        liList.forEach(li => {

            // 检查是否已经存在下载按钮
            const existingDownloadButton = li.querySelector('.download-button');
            if (existingDownloadButton) return

            const awemeId = li.childNodes[0].children[0].getAttribute('href').split('/').pop();
            let matchingData = mergedVideoList.find(data => data.aweme_id === awemeId) || mergedPicsList.find(data => data.aweme_id === awemeId);

            if (matchingData) {
                // 添加自定义属性 data-aweme-id 到 <li> 元素
                li.setAttribute('data-aweme-id', matchingData.aweme_id);

                // 在匹配的 <li> 元素中添加下载按钮
                const buttonContainer = document.createElement('div');
                const downloadButton = document.createElement('button');
                // downloadButton.classList = "B10aL8VQ s6mStVxD vMQD6aai vk7WaOg_ a2I1sBCL tAofAbwG"
                buttonContainer.style.position = 'absolute';
                buttonContainer.style.top = '85%';
                buttonContainer.style.right = '0';
                downloadButton.style.fontSize = '10px'
                downloadButton.style.color = 'white'
                downloadButton.style.background = 'grey'
                downloadButton.style.padding = '4px 8px'
                downloadButton.style.borderRadius = '4px'


                const isDownloaded = localStorage.getItem('downloaded_' + awemeId);
                if (isDownloaded) {
                    downloadButton.innerText = "下载 ✔"
                } else {
                    downloadButton.innerText = "下载"
                }
                downloadButton.classList.add('download-button'); // 添加适当的类名或 ID
                downloadButton.addEventListener("click", (e) => {

                    if (matchingData.media_type === 4) {
                        let videoUrl = getVideoUrl(matchingData)
                        if (videoUrl) {
                            downloadVideo(convertHttpToHttps(videoUrl), generateVideoFilename(matchingData)).then()
                            localStorage.setItem('downloaded_' + awemeId, true);
                            downloadButton.innerText = "下载 ✔"
                        } else {
                            alert("未找到下载地址 " + matchingData.desc)
                        }
                    } else if (matchingData.media_type === 2) {
                        downloadPics(matchingData).then(r => {
                        })
                        localStorage.setItem('downloaded_' + awemeId, true);
                        downloadButton.innerText = "下载 ✔"
                    }
                })
                buttonContainer.appendChild(downloadButton);
                li.appendChild(buttonContainer);
            }
        });
    }

    function getVideoUrl(matchingData) {
        let videoUrl = null;

        if (matchingData.video.play_addr_h264 && matchingData.video.play_addr_h264.url_list.length > 0) {
            videoUrl = matchingData.video.play_addr_h264.url_list[0];
        } else if (matchingData.video.play_addr && matchingData.video.play_addr.url_list.length > 0) {
            videoUrl = matchingData.video.play_addr.url_list[0];
        } else if (matchingData.video.play_addr_265 && matchingData.video.play_addr_265.url_list.length > 0) {
            videoUrl = matchingData.video.play_addr_265.url_list[0];
        }

        return videoUrl;
    }

    function generateVideoFilename(videoInfo) {
        const {author, create_time, desc, author_user_id} = videoInfo;
        const formattedTimestamp = formatTimestamp(create_time);
        return `${author.nickname}_${formattedTimestamp}_video_${desc}_${author_user_id}.mp4`;
    }

    function generatePicsFilename(picsInfo) {
        const {author, create_time, desc, author_user_id} = picsInfo;
        const formattedTimestamp = formatTimestamp(create_time);
        return `${author.nickname}_${formattedTimestamp}_pics_${desc}_${author_user_id}`;
    }

    function logme(string) {
        console.log("|||" + string)
    }

})();