Greasy Fork

Greasy Fork is available in English.

抖音主页视频图文下载

拦截抖音主页接口,获取用户信息和视频列表数据,于视频、图文下载

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         抖音主页视频图文下载
// @namespace    douyin-homepage-download
// @version      1.0.1
// @description  拦截抖音主页接口,获取用户信息和视频列表数据,于视频、图文下载
// @author       chrngfu
// @match        https://www.douyin.com/*
// @license      MIT
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// ==/UserScript==

(function () {
    'use strict';

    // 使用 GM_addStyle 添加 CSS 样式
    GM_addStyle(`
        #videoTableContainer {
            width: 90%;
            height: 80%;
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background-color: #fff;
            padding: 20px;
            z-index: 10000;
            border: 1px solid #ccc;
            box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
            overflow: hidden;
            display: flex;
            flex-direction: column;
        }
        #videoTableContainer h3 {
            margin: 0 0 10px 0;
        }
        #videoTableContainer table {
            width: 100%;
            border-collapse: collapse;
            table-layout: fixed;
        }
        #videoTableContainer table th,
        #videoTableContainer table td {
            border: 1px solid #ddd;
            padding: 8px;
            text-align: left;
            vertical-align: middle; /* 上下居中 */
        }
        #videoTableContainer table th {
            text-align: center;
            background-color: #f2f2f2;
            font-weight: bold;
        }
        #videoTableContainer table tr {
            height: 50px; /* 固定每行高度 */
        }
        #videoTableContainer table tr:nth-child(even) {
            background-color: #f9f9f9;
        }
        #videoTableContainer table tr:hover {
            background-color: #f1f1f1;
        }
        #videoTableContainer table td.center {
            text-align: center; /* 左右居中 */
        }
        #videoTableContainer .cover-image {
            max-width: 100px;
            max-height: 50px;
            display: block;
            margin: 0 auto;
        }
        #videoTableContainer .filters {
            margin-bottom: 10px;
        }
        #videoTableContainer .filters select,
        #videoTableContainer .filters input {
            margin-right: 10px;
        }
        #videoTableContainer .actions {
            margin-bottom: 10px;
        }
        #videoTableContainer .actions button {
            margin-right: 10px;
        }
        #videoTableContainer #progressBar {
            width: 100%;
            height: 10px;
            background-color: #e0e0e0;
            margin-top: 10px;
        }
        #videoTableContainer #progress {
            width: 0%;
            height: 100%;
            background-color: #76c7c0;
        }
        #videoTableContainer #videoTableWrapper {
            flex: 1;
            overflow-y: auto;
        }
    `);

    // 获取 Aweme 名称
    function getAwemeName(aweme) {
        let name = aweme.item_title ? aweme.item_title : aweme.caption;
        if (!name) name = aweme.desc ? aweme.desc : aweme.awemeId;
        return (aweme.date ? `【${aweme.date.slice(0, 10)}】` : "") + name.replace(/[\/:*?"<>|\s]+/g, "").slice(0, 27).replace(/\.\d+$/g, "");
    }

    // 拦截 XHR 请求
    const originalOpen = XMLHttpRequest.prototype.open;
    const originalSend = XMLHttpRequest.prototype.send;

    XMLHttpRequest.prototype.open = function (method, url) {
        this._url = url; // 保存请求的 URL
        return originalOpen.apply(this, arguments);
    };

    XMLHttpRequest.prototype.send = function (body) {
        // 监听请求完成事件
        this.addEventListener('load', function () {
            if (this._url.includes('/aweme/v1/web/user/profile/other')) {
                // 用户主页信息
                const userProfile = JSON.parse(this.responseText);
                console.log('原始用户主页信息:', userProfile);

                // 格式化用户信息
                const formattedUserInfo = formatUserData(userProfile.user || {});
                console.log('格式化后的用户信息:', formattedUserInfo);

                // 缓存用户信息
                cacheUserInfo(formattedUserInfo);
            } else if (this._url.includes('/aweme/v1/web/aweme/post/')) {
                // 主页视频列表信息
                const videoList = JSON.parse(this.responseText);
                console.log('主页视频列表信息:', videoList);
                processVideoList(videoList);
            }
        });

        return originalSend.apply(this, arguments);
    };

    // 格式化用户信息
    function formatUserData(userInfo) {
        for (let key in userInfo) {
            if (!userInfo[key]) userInfo[key] = ""; // 确保每个字段都有值
        }
        return {
            uid: userInfo.uid,
            nickname: userInfo.nickname,
            following_count: userInfo.following_count,
            mplatform_followers_count: userInfo.mplatform_followers_count,
            total_favorited: userInfo.total_favorited,
            unique_id: userInfo.unique_id ? userInfo.unique_id : userInfo.short_id,
            ip_location: userInfo.ip_location ? userInfo.ip_location.replace("IP属地:", "") : "",
            gender: userInfo.gender ? "男女".charAt(userInfo.gender).trim() : "",
            city: [userInfo.province, userInfo.city, userInfo.district].filter((x) => x).join("·"), // 合并城市信息
            signature: userInfo.signature,
            aweme_count: userInfo.aweme_count,
            create_time: Date.now()
        };
    }

    // 格式化日期
    function formatDate(date, fmt) {
        date = new Date(date * 1000);
        let o = {
            "M+": date.getMonth() + 1, //月份
            "d+": date.getDate(), //日
            "H+": date.getHours(), //小时
            "m+": date.getMinutes(), //分
            "s+": date.getSeconds(), //秒
            "q+": Math.floor((date.getMonth() + 3) / 3), //季度
            "S": date.getMilliseconds() //毫秒
        };
        if (/(y+)/.test(fmt)) fmt = fmt.replace(RegExp.$1, (date.getFullYear() + "").substr(4 - RegExp.$1.length));
        for (let k in o)
            if (new RegExp("(" + k + ")").test(fmt))
                fmt = fmt.replace(RegExp.$1, (RegExp.$1.length === 1) ? (o[k]) : (("00" + o[k]).substr(("" + o[k]).length)));
        return fmt;
    }

    // 格式化秒数为时间字符串
    function formatSeconds(value) {
        let secondTime = parseInt(value);
        let minuteTime = 0;
        let hourTime = 0;
        if (secondTime > 60) {
            minuteTime = parseInt(secondTime / 60);
            secondTime = parseInt(secondTime % 60);
            if (minuteTime >= 60) {
                hourTime = parseInt(minuteTime / 60);
                minuteTime = parseInt(minuteTime % 60);
            }
        }
        let result = "" + parseInt(secondTime) + "秒";
        if (minuteTime > 0) {
            result = "" + parseInt(minuteTime) + "分钟" + result;
        }
        if (hourTime > 0) {
            result = "" + parseInt(hourTime) + "小时" + result;
        }
        return result;
    }

    // 缓存用户信息
    function cacheUserInfo(userInfo) {
        const cachedData = GM_getValue('cachedUserInfo', {}); // 获取缓存
        cachedData[userInfo.uid] = userInfo; // 按 UID 存储
        GM_setValue('cachedUserInfo', cachedData); // 更新缓存
        console.log('用户信息已缓存:', userInfo);
    }

    // 处理视频列表数据
    function processVideoList(videoList) {
        if (videoList.aweme_list) {
            const formattedVideos = videoList.aweme_list.map(formatDouyinAwemeData);
            console.log('格式化后的视频列表:', formattedVideos);

            // 缓存视频列表信息
            cacheVideoList(new Map(formattedVideos.map(video => [video.awemeId, video])));
        }
    }

    // 格式化 Douyin 视频数据
    function formatDouyinAwemeData(item) {
        return {
            awemeId: item.aweme_id,
            item_title: item.item_title || '',
            caption: item.caption || '',
            desc: item.desc || '',
            type: item.images ? "图文" : "视频",
            tag: (item.text_extra || []).map(tag => tag.hashtag_name).filter(tag => tag).join("#"),
            video_tag: (item.video_tag || []).map(tag => tag.tag_name).filter(tag => tag).join("->"),
            date: formatDate(item.create_time, "yyyy-MM-dd HH:mm:ss"),
            create_time: item.create_time,
            ...item.statistics && {
                diggCount: item.statistics.digg_count,
                commentCount: item.statistics.comment_count,
                collectCount: item.statistics.collect_count,
                shareCount: item.statistics.share_count
            },
            ...item.video && {
                duration: formatSeconds(Math.round(item.video.duration / 1e3)),
                url: item.video.play_addr.url_list[0],
                cover: item.video.cover.url_list[0],
                images: item.images ? item.images.map(row => row.url_list.pop()) : null
            },
            ...item.author && {
                uid: item.author.uid,
                nickname: item.author.nickname
            }
        };
    }

    // 缓存视频列表信息
    function cacheVideoList(videos) {
        const cachedData = new Map(GM_getValue('cachedVideoList', [])); // 获取缓存并转换为 Map

        videos.forEach((video, awemeId) => {
            cachedData.set(awemeId, video); // 设置新视频
        });

        GM_setValue('cachedVideoList', Array.from(cachedData.entries())); // 更新缓存
        console.log('视频列表已缓存:', Array.from(cachedData.values()));
    }

    // 显示视频列表信息
    function displayVideoList() {
        // 先移除旧的表格容器
        const oldTableContainer = document.getElementById('videoTableContainer');
        if (oldTableContainer) document.body.removeChild(oldTableContainer);

        const videosArray = GM_getValue('cachedVideoList', []);
        const videos = new Map(videosArray);
        const authors = [...new Set(Array.from(videos.values()).map(video => video.nickname))];
        const types = ["视频", "图文"];

        const tableContainer = document.createElement('div');
        tableContainer.id = 'videoTableContainer';
        tableContainer.innerHTML = `
            <button id="closeButton" style="position:absolute;top:10px;right:10px;background-color:#f44336;color:white;border:none;padding:5px 10px;cursor:pointer;">关闭</button>
            <div class="filters">
                <label for="authorFilter">作者:</label>
                <select id="authorFilter">
                    <option value="">全部</option>
                    ${authors.map(author => `<option value="${author}">${author}</option>`).join('')}
                </select>
                <label for="typeFilter">类型:</label>
                <select id="typeFilter">
                    <option value="">全部</option>
                    ${types.map(type => `<option value="${type}">${type}</option>`).join('')}
                </select>
            </div>
            <div class="actions">
                <button id="downloadSelected">下载选中内容</button>
                <button id="clearSelected">清除选中内容</button>
            </div>
            <p id="downloadStatus"></p>
            <div id="progressBar"><div id="progress"></div></div>
            <h3>视频列表</h3>
            <div id="videoTableWrapper">
                <table id="videoTable">
                    <thead>
                        <tr>
                            <th style="width:55px;"><input type="checkbox" id="selectAll"></th>
                            <th style="width:120px;">封面</th>
                            <th style="width:180px;">标题</th>
                            <th>描述</th>
                            <th style="width:120px;">类型</th>
                            <th>标签</th>
                            <th style="width:240px;">发布时间</th>
                            <th style="width:120px;">点赞数</th>
                            <th style="width:120px;">评论数</th>
                            <th style="width:120px;">分享数</th>
                            <th style="width:120px;">收藏数</th>
                            <th style="width:120px;">时长</th>
                            <th style="width:120px;">作者</th>
                        </tr>
                    </thead>
                    <tbody>
                        ${Array.from(videos.values()).map(video => `
                            <tr>
                                <td class="center"><input type="checkbox" class="videoCheckbox" data-id="${video.awemeId}"></td>
                                <td class="center"><img src="${video.cover || (video.images ? video.images[0] : '')}" class="cover-image" /></td>
                                <td title="${video.item_title}" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${video.item_title}</td>
                                <td title="${video.desc}" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${video.desc}</td>
                                <td class="center">${video.type}</td>
                                <td title="${video.tag}">${video.tag}</td>
                                <td class="center">${video.date}</td>
                                <td class="center">${video.diggCount || 0}</td>
                                <td class="center">${video.commentCount || 0}</td>
                                <td class="center">${video.shareCount || 0}</td>
                                <td class="center">${video.collectCount || 0}</td>
                                <td class="center">${video.duration}</td>
                                <td class="center">${video.nickname}</td>
                            </tr>
                        `).join('')}
                    </tbody>
                </table>
            </div>
        `;
        document.body.appendChild(tableContainer);

        // 绑定关闭按钮事件
        document.getElementById('closeButton').addEventListener('click', () => {
            document.body.removeChild(tableContainer);
        });

        // 修改全选按钮的逻辑
        document.getElementById('selectAll').addEventListener('change', function () {
            const isChecked = this.checked;
            // 仅选中当前筛选条件下显示的视频
            document.querySelectorAll('#videoTable tbody tr').forEach(row => {
                if (row.style.display !== 'none') {
                    const checkbox = row.querySelector('.videoCheckbox');
                    if (checkbox) checkbox.checked = isChecked;
                }
            });
        });

        document.getElementById('authorFilter').addEventListener('change', filterTable);
        document.getElementById('typeFilter').addEventListener('change', filterTable);

        document.getElementById('downloadSelected').addEventListener('click', downloadSelectedItems);

        // 新增:清除选中内容
        document.getElementById('clearSelected').addEventListener('click', clearSelectedItems);
    }

    // 过滤表单(改为动态生成表格内容)
    function filterTable() {
        const authorFilter = document.getElementById('authorFilter').value;
        const typeFilter = document.getElementById('typeFilter').value;
        const videosArray = GM_getValue('cachedVideoList', []);
        const videos = new Map(videosArray);

        // 重新生成表格内容
        const tbody = document.querySelector('#videoTable tbody');
        tbody.innerHTML = Array.from(videos.values())
            .filter(video => {
            const matchAuthor = !authorFilter || video.nickname === authorFilter;
            const matchType = !typeFilter || video.type === typeFilter;
            return matchAuthor && matchType;
        })
            .map(video => `
                <tr>
                    <td class="center"><input type="checkbox" class="videoCheckbox" data-id="${video.awemeId}"></td>
                    <td class="center"><img src="${video.cover || (video.images ? video.images[0] : '')}" class="cover-image" /></td>
                    <td title="${video.item_title}" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${video.item_title}</td>
                    <td title="${video.desc}" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${video.desc}</td>
                    <td class="center">${video.type}</td>
                    <td title="${video.tag}">${video.tag}</td>
                    <td class="center">${video.date}</td>
                    <td class="center">${video.diggCount || 0}</td>
                    <td class="center">${video.commentCount || 0}</td>
                    <td class="center">${video.shareCount || 0}</td>
                    <td class="center">${video.collectCount || 0}</td>
                    <td class="center">${video.duration}</td>
                    <td class="center">${video.nickname}</td>
                </tr>
            `)
            .join('');
    }

    // 下载选中的项目
    async function downloadSelectedItems() {
        const selectedCheckboxes = document.querySelectorAll('.videoCheckbox:checked');
        const videosArray = GM_getValue('cachedVideoList', []);
        const videos = new Map(videosArray);
        const selectedVideos = Array.from(selectedCheckboxes).map(cb => videos.get(cb.getAttribute('data-id')));
        const totalCount = selectedVideos.length;

        if (totalCount === 0) {
            alert('请选择要下载的内容。');
            return;
        }

        const firstType = selectedVideos[0].type;
        if (selectedVideos.some(video => video.type !== firstType)) {
            alert('只能选择同一种类型的项目进行下载。');
            return;
        }

        const statusElement = document.getElementById('downloadStatus');
        statusElement.textContent = `正在下载... 已下载 0/${totalCount} 项`;
        document.getElementById('progress').style.width = '0%';

        // 如果只选中一个视频,直接下载
        if (totalCount === 1 && firstType === '视频') {
            const video = selectedVideos[0];
            try {
                const response = await fetch(video.url);
                const blob = await response.blob();
                const url = URL.createObjectURL(blob);
                const a = document.createElement('a');
                a.href = url;
                a.download = `${getAwemeName(video)}.mp4`;
                a.click();
                URL.revokeObjectURL(url);
                statusElement.textContent = '下载完成!';
                alert('下载完成!');
            } catch (error) {
                console.error('下载失败:', error);
                statusElement.textContent = '下载失败,请重试。';
            }
            return;
        }

        // 多个文件时使用 ZIP 压缩
        const zip = new JSZip();
        let downloadedCount = 0;

        for (const video of selectedVideos) {
            await downloadAndAddToZip(zip, video, firstType);
            downloadedCount++;
            statusElement.textContent = `正在下载... 已下载 ${downloadedCount}/${totalCount} 项`;
            document.getElementById('progress').style.width = `${(downloadedCount / totalCount) * 100}%`;
        }

        const content = await zip.generateAsync({ type: 'blob' });
        saveAs(content, `[${firstType}]${selectedVideos[0]?.nickname}.zip`);
        statusElement.textContent = '下载完成!';
        alert('下载完成!');
    }

    // 下载单个项目并添加到 ZIP 文件
    async function downloadAndAddToZip(zip, video, type) {
        try {
            if (type === '视频') {
                const response = await fetch(video.url);
                const blob = await response.blob();
                zip.file(`${getAwemeName(video)}.mp4`, blob);
            } else if (type === '图文') {
                const folder = zip.folder(getAwemeName(video));
                for (let j = 0; j < video.images.length; j++) {
                    const imgResponse = await fetch(video.images[j]);
                    const imgBlob = await imgResponse.blob();
                    folder.file(`image_${j + 1}.jpg`, imgBlob);
                }
            }
        } catch (error) {
            console.error(`下载失败:`, error);
            throw error; // 抛出错误,以便外层捕获
        }
    }

    // 清除选中的项目
    function clearSelectedItems() {
        const selectedCheckboxes = document.querySelectorAll('.videoCheckbox:checked');
        if (selectedCheckboxes.length === 0) {
            alert('请先选择要清除的内容。');
            return;
        }

        const videosArray = GM_getValue('cachedVideoList', []);
        const videos = new Map(videosArray);

        // 从缓存中删除选中的视频
        selectedCheckboxes.forEach(checkbox => {
            const awemeId = checkbox.getAttribute('data-id');
            videos.delete(awemeId); // 从 Map 中删除
        });

        // 更新缓存
        GM_setValue('cachedVideoList', Array.from(videos.entries()));
        console.log('已清除选中的内容:', Array.from(videos.values()));

        // 刷新表格
        displayVideoList();
        alert('已清除选中的内容!');
    }

    // 创建按钮
    const button = document.createElement('button');
    button.innerText = '显示数据列表';
    button.style.position = 'fixed';
    button.style.bottom = '20px';
    button.style.right = '20px';
    button.style.zIndex = '10001';
    button.onclick = displayVideoList;
    document.body.appendChild(button);

    console.log('抖音主页视频图文下载脚本已加载!');
})();