Greasy Fork

Greasy Fork is available in English.

抖音主页视频图文下载

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

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

// ==UserScript==
// @name         抖音主页视频图文下载
// @namespace    douyin-homepage-download
// @version      1.0.2
// @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';

    // 新增:作者信息展示区域
    function createAuthorInfoBox() {
        const authorInfoBox = document.createElement('div');
        authorInfoBox.id = 'authorInfoBox';
        authorInfoBox.style.marginBottom = '10px';
        authorInfoBox.style.padding = '10px';
        authorInfoBox.style.backgroundColor = '#f9f9f9';
        authorInfoBox.style.border = '1px solid #ddd';
        authorInfoBox.style.borderRadius = '4px';
        authorInfoBox.style.display = 'none'; // 默认隐藏
        authorInfoBox.innerHTML = `
            <h4 style="margin: 0 0 10px 0;">作者信息</h4>
            <div style="display: flex; flex-wrap: wrap; gap: 10px;">
                <div><strong>昵称:</strong><span id="authorNickname">-</span></div>
                <div><strong>粉丝数:</strong><span id="authorFollowers">-</span></div>
                <div><strong>获赞数:</strong><span id="authorLikes">-</span></div>
                <div><strong>作品数:</strong><span id="authorWorks">-</span></div>
                <div><strong>IP 属地:</strong><span id="authorIP">-</span></div>
            </div>
        `;
        return authorInfoBox;
    }

    // 新增:友好提示函数
    function showFriendlyMessage(message, isSuccess = true) {
        const msgBox = document.createElement('div');
        msgBox.style.position = 'fixed';
        msgBox.style.top = '20px';
        msgBox.style.left = '50%';
        msgBox.style.transform = 'translateX(-50%)';
        msgBox.style.padding = '10px 20px';
        msgBox.style.backgroundColor = isSuccess ? '#4CAF50' : '#f44336';
        msgBox.style.color = 'white';
        msgBox.style.borderRadius = '4px';
        msgBox.style.zIndex = '100000';
        msgBox.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
        msgBox.textContent = message;
        document.body.appendChild(msgBox);

        setTimeout(() => {
            document.body.removeChild(msgBox);
        }, 3000);
    }

    // 使用 GM_addStyle 添加 CSS 样式
    GM_addStyle(`
        /* 新增禁用按钮样式 */
        button:disabled {
            opacity: 0.6;
            cursor: not-allowed;
        }
        #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>
            <!-- 新增作者信息展示区域 -->
            ${createAuthorInfoBox().outerHTML}
            <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('authorFilter').addEventListener('change', filterTable);
        document.getElementById('typeFilter').addEventListener('change', filterTable);
    }

    // 过滤表单(改为动态生成表格内容)
    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 authorInfoBox = document.getElementById('authorInfoBox');
        const authorNickname = document.getElementById('authorNickname');
        const authorFollowers = document.getElementById('authorFollowers');
        const authorLikes = document.getElementById('authorLikes');
        const authorWorks = document.getElementById('authorWorks');
        const authorIP = document.getElementById('authorIP');

        if (authorFilter) {
            const selectedAuthor = Array.from(videos.values()).find(video => video.nickname === authorFilter);
            if (selectedAuthor) {
                authorNickname.textContent = selectedAuthor.nickname;
                authorFollowers.textContent = selectedAuthor.mplatform_followers_count || '-';
                authorLikes.textContent = selectedAuthor.total_favorited || '-';
                authorWorks.textContent = selectedAuthor.aweme_count || '-';
                authorIP.textContent = selectedAuthor.ip_location || '-';
                authorInfoBox.style.display = 'block'; // 显示作者信息
            }
        } else {
            authorInfoBox.style.display = 'none'; // 隐藏作者信息
        }

        // 新增:按钮禁用逻辑
        const downloadBtn = document.getElementById('downloadSelected');
        const clearBtn = document.getElementById('clearSelected');
        const isFilterEmpty = !authorFilter && !typeFilter;
        downloadBtn.disabled = isFilterEmpty;
        clearBtn.disabled = isFilterEmpty;
        // 重新生成表格内容
        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 authorFilter = document.getElementById('authorFilter').value;
        const typeFilter = document.getElementById('typeFilter').value;
        const videosArray = GM_getValue('cachedVideoList', []);
        const videos = new Map(videosArray);

        // 新增:更新作者信息
        const authorInfoBox = document.getElementById('authorInfoBox');
        const authorNickname = document.getElementById('authorNickname');
        const authorFollowers = document.getElementById('authorFollowers');
        const authorLikes = document.getElementById('authorLikes');
        const authorWorks = document.getElementById('authorWorks');
        const authorIP = document.getElementById('authorIP');

        if (authorFilter) {
            const selectedAuthor = Array.from(videos.values()).find(video => video.nickname === authorFilter);
            if (selectedAuthor) {
                authorNickname.textContent = selectedAuthor.nickname;
                authorFollowers.textContent = selectedAuthor.mplatform_followers_count || '-';
                authorLikes.textContent = selectedAuthor.total_favorited || '-';
                authorWorks.textContent = selectedAuthor.aweme_count || '-';
                authorIP.textContent = selectedAuthor.ip_location || '-';
                authorInfoBox.style.display = 'block'; // 显示作者信息
            }
        } else {
            authorInfoBox.style.display = 'none'; // 隐藏作者信息
        }

        const selectedCheckboxes = document.querySelectorAll('.videoCheckbox:checked');
        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`);
        showFriendlyMessage('🎉 下载完成!');
        statusElement.textContent = '下载完成!';
    }

    // 下载单个项目并添加到 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();
        showFriendlyMessage('🗑️ 已清除选中内容!');
    }

    // 创建按钮
    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('抖音主页视频图文下载脚本已加载!');
})();