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