抖音主页视频图文下载
// ==UserScript==
// @name 抖音主页视频图文下载
// @namespace douyin-homepage-download
// @version 0.1.3
// @author chrngfu
// @description 抖音主页视频图文下载
// @icon https://lf1-cdn-tos.bytegoofy.com/goofy/ies/douyin_web/public/favicon.ico
// @match https://www.douyin.com/*
// @require https://cdn.jsdelivr.net/npm/vue@3.5.13/dist/vue.global.prod.js
// @require https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js
// @require https://unpkg.com/element-plus@2.9.3/dist/index.full.min.js
// @resource element-plus/dist/index.css https://cdn.jsdelivr.net/npm/element-plus@2.9.3/dist/index.css
// @grant GM_addStyle
// @grant GM_getResourceText
// @grant GM_registerMenuCommand
// @run-at document-start
// ==/UserScript==
(function (vue, ElementPlus, JSZip) {
'use strict';
const cssLoader = (e) => {
const t = GM_getResourceText(e);
return GM_addStyle(t), t;
};
cssLoader("element-plus/dist/index.css");
const formatNumber = (num) => {
num = vue.toRaw(num);
num = num + "";
return num.replace(new RegExp("\\B(?<!\\.\\d*)(?=(\\d{3})+(?!\\d))", "g"), ",");
};
const addZero = (num) => {
return (num + "").padStart(2, "0");
};
const formatData = (timestamp, fmt = "yyyy-MM-dd") => {
if (timestamp.toString().length === 10) timestamp *= 1e3;
const date = new Date(timestamp);
const year = date.getFullYear().toString();
const month = date.getMonth() + 1;
const day = date.getDate();
const hour = date.getHours();
const minute = date.getMinutes();
const second = date.getSeconds();
fmt = fmt.replace("yyyy", year);
fmt = fmt.replace("MM", addZero(month));
fmt = fmt.replace("dd", addZero(day));
fmt = fmt.replace("HH", addZero(hour));
fmt = fmt.replace("mm", addZero(minute));
fmt = fmt.replace("ss", addZero(second));
return fmt;
};
function formatSeconds(seconds) {
const timeUnits = ["小时", "分", "秒"];
const timeValues = [Math.floor(seconds / 3600), Math.floor(seconds % 3600 / 60), seconds % 60];
return timeValues.map((value, index) => value > 0 ? value + timeUnits[index] : "").join("");
}
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, "");
}
const all_aweme_map = /* @__PURE__ */ new Map();
function interceptResponse() {
console.warn("开始拦截响应!");
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function() {
originalSend.apply(this, arguments);
if (!this._url) return;
this.url = this._url;
if (this.url.startsWith("http")) this.url = new URL(this.url).pathname;
if (!this.url.startsWith("/aweme/v1/web/")) return;
const self = this;
let func = this.onreadystatechange;
this.onreadystatechange = (e) => {
if (self.readyState === 4) {
let data = JSON.parse(self.response);
if (self.url.startsWith("/aweme/v1/web/user/profile/other")) {
const userInfo = formatUserData(data.user);
sessionStorage.setItem("userInfo", JSON.stringify(userInfo));
console.log(
`%c已拦截到用户主页信息`,
"background: #009688; color: #fff; padding: 2px 5px; border-radius: 2px"
);
}
if (self.url.startsWith("/aweme/v1/web/aweme/post/")) {
const dataList = formatAwemeData(data);
if (dataList) {
const userInfo = JSON.parse(sessionStorage.getItem("userInfo"));
if (dataList.some((r) => r.uid !== userInfo.uid)) {
console.warn("检测到用户已切换,自动刷新捕获数据");
all_aweme_map.clear();
sessionStorage.removeItem("all_aweme_list");
}
dataList.filter((item) => item.url && item.awemeId).forEach((aweme) => {
all_aweme_map.set(aweme.awemeId, aweme);
});
console.log(
`%c已捕获到 ${all_aweme_map.size} 条数据`,
"background: #009688; color: #fff; padding: 2px 5px; border-radius: 2px"
);
sessionStorage.setItem("all_aweme_list", JSON.stringify(Array.from(all_aweme_map.values())));
}
}
}
if (func) func.apply(self, e);
};
};
}
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.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 formatAwemeData(json_data) {
var _a;
return (_a = json_data == null ? undefined : json_data.aweme_list) == null ? undefined : _a.map(formatDouyinAwemeData);
}
const formatDouyinAwemeData = (item) => Object.assign(
{
awemeId: item.aweme_id,
item_title: item.item_title,
caption: item.caption,
desc: item.desc,
type: item.images ? "图文" : "视频",
tag: item.text_extra ? item.text_extra.map((tag) => tag.hashtag_name).filter((tag) => tag).join("#") : "",
video_tag: item.video_tag ? item.video_tag.map((tag) => tag.tag_name).filter((tag) => tag).join("->") : "",
date: formatData(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
} : {}
);
interceptResponse();
var _GM_registerMenuCommand = /* @__PURE__ */ (() => typeof GM_registerMenuCommand != "undefined" ? GM_registerMenuCommand : undefined)();
const _hoisted_1 = { style: { "margin": "10px 0" } };
const _hoisted_2 = { class: "dialog-footer" };
const _sfc_main$1 = {
__name: "aweme-data-dialog",
setup(__props) {
const dialogVisible = vue.ref(false);
const userInfo = vue.ref({});
const videoCount = vue.ref(0);
const imageCount = vue.ref(0);
const allAwemeList = vue.ref([]);
const handleDataDialogOpen = () => {
const _userInfo = JSON.parse(sessionStorage.getItem("userInfo"));
const _allAwemeList = JSON.parse(sessionStorage.getItem("all_aweme_list"));
if (!(_userInfo == null ? undefined : _userInfo.uid) || !(_allAwemeList == null ? undefined : _allAwemeList.length)) {
ElementPlus.ElMessage.warning("未捕获到用户信息或作品列表,请刷新页面后重试");
return;
}
userInfo.value = _userInfo;
allAwemeList.value = _allAwemeList.sort((a, b) => b.create_time - a.create_time);
videoCount.value = _allAwemeList.filter((item) => !(item == null ? undefined : item.images)).length;
imageCount.value = _allAwemeList.filter((item) => item == null ? undefined : item.images).length;
dialogVisible.value = true;
};
vue.onMounted(() => {
_GM_registerMenuCommand("查看全部已加载数据", handleDataDialogOpen);
});
const downloadFile = async (url, filename, ext = "mp4") => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`网络请求失败,状态码: ${response.status}`);
}
const blob = await response.blob();
return blob;
} catch (error) {
console.error(`${ext === "mp4" ? "视频" : "图片"}下载失败:`, error);
return null;
}
};
const createDownloadLink = (blob, filename, ext, prefix = "") => {
var _a;
if (!blob) return;
filename = filename || ((_a = userInfo.value) == null ? undefined : _a.nickname) || document.title;
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${prefix}${filename.replace(/[\/:*?"<>|\s]/g, "").slice(0, 40)}.${ext}`;
link.click();
URL.revokeObjectURL(url);
};
const handleDownloadAllVideos = async () => {
if (videoCount.value === 0) {
ElementPlus.ElMessage.warning("暂未发现视频,请继续滚动加载或刷新页面后重试");
return;
}
const batchSize = 5;
const totalVideos = videoCount.value;
const loading = ElementPlus.ElLoading.service({
lock: true,
text: `视频正在下载中,请稍后...`,
background: "rgba(0, 0, 0, 0.7)"
});
const zip = new JSZip();
let start = 0;
let currentBatch = [];
const downloadBatch = async (batchIndex) => {
const batchStart = batchIndex * batchSize;
const batchEnd = Math.min(batchStart + batchSize, allAwemeList.value.length);
if (batchStart >= batchEnd) return;
const currentBatchPromises = [];
for (let index = batchStart; index < batchEnd; index++) {
const item = allAwemeList.value[index];
if (!item.images && item.url) {
currentBatch.push(item);
const downloadPromise = downloadFile(item.url, getAwemeName(item), "mp4").then((blob) => {
const filename = `${getAwemeName(item)}.mp4`;
zip.file(filename, blob);
}).catch((error) => {
console.error(`下载视频失败: ${error}`);
});
currentBatchPromises.push(downloadPromise);
}
}
Promise.all(currentBatchPromises).then(() => {
start = currentBatch.length;
loading.setText(`视频正在下载中(${start}/${totalVideos}),请稍后...`);
if (batchEnd < allAwemeList.value.length) {
downloadBatch(batchIndex + 1);
} else {
loading.setText("视频下载完成,正在打包中...");
zip.generateAsync({ type: "blob" }).then((blob) => {
createDownloadLink(blob, `【视频】${userInfo.value.nickname}`, "zip");
loading.close();
}).catch((error) => {
loading.close();
console.error("打包视频失败:", error);
});
}
}).catch((error) => {
console.error("视频下载失败:", error);
});
};
downloadBatch(0);
};
const handleDownloadAllImages = async () => {
if (imageCount.value === 0) {
ElementPlus.ElMessage.warning("暂未发现图文,请继续滚动加载或刷新页面后重试");
return;
}
const loading = ElementPlus.ElLoading.service({ lock: true, text: "图文正在下载中,请稍后..." });
const zip = new JSZip();
let start = 0;
for (let index = 0; index < allAwemeList.value.length; index++) {
const item = allAwemeList.value[index];
if (item.images) {
start++;
loading.setText(`图文正在下载中(${start}/${imageCount.value}),请稍后...`);
const folder = zip.folder(getAwemeName(item));
await Promise.all(
item.images.map(async (link, index2) => {
const blob = await downloadFile(link, `image_${index2 + 1}`, "jpg");
if (blob) {
folder.file(`image_${index2 + 1}.jpg`, blob);
}
})
);
}
}
zip.generateAsync({ type: "blob" }).then((content) => {
createDownloadLink(content, `【图文】${userInfo.value.nickname}`, "zip");
loading.close();
}).catch((error) => {
loading.close();
console.error("打包图片失败:", error);
});
};
const handleDownload = (row) => {
const _row = vue.toRaw(row);
if (_row == null ? undefined : _row.images) {
downloadImages(getAwemeName(_row), _row.images);
} else {
downloadVideo(getAwemeName(_row), row.url);
}
};
const downloadImages = async (filename, urls) => {
const loading = ElementPlus.ElLoading.service({ lock: true, text: "图片正在下载中,请稍后..." });
const zip = new JSZip();
for (let i = 0; i < urls.length; i++) {
const url = urls[i];
const blob = await downloadFile(url, `image_${i + 1}`, "jpg");
if (blob) {
zip.file(`image_${i + 1}.jpg`, blob);
}
}
zip.generateAsync({ type: "blob" }).then((blob) => {
createDownloadLink(blob, filename, "zip", "【图文】");
loading.close();
}).catch((error) => {
loading.close();
console.error("下载图片失败:", error);
});
};
const downloadVideo = async (filename, url) => {
const blob = await downloadFile(url, filename, "mp4");
createDownloadLink(blob, filename, "mp4");
};
const handleExport = () => {
if (allAwemeList.value.length === 0) {
ElementPlus.ElMessage.warning("暂未发现视频和图文数据,请继续滚动加载或刷新页面后重试");
return;
}
const loading = ElementPlus.ElLoading.service({ lock: true, text: "数据正在导出中,请稍后..." });
try {
let text = "作者昵称,封面,类型,标题,Tag,点赞数,收藏数,评论数,分享数,发布时间,描述,时长,分类,作品链接,下载链接\n";
allAwemeList.value.forEach((aweme) => {
text += [
aweme.nickname,
aweme.cover,
aweme.type,
aweme.caption,
aweme.tag,
aweme.diggCount,
aweme.collectCount,
aweme.commentCount,
aweme.shareCount,
aweme.date,
'"' + aweme.desc.replace(/,/g, ",").replace(/"/g, '""') + '"',
aweme.duration,
aweme.video_tag,
"https://www.douyin.com/video/" + aweme.awemeId,
aweme.url
].join(",") + "\n";
});
const filename = `【${userInfo.value.nickname}】数据导出 - ${formatData(Date.now(), "yyyy-MM-dd HH:mm:ss")}`;
createDownloadLink(new Blob([text], { type: "text/plain" }), filename, "csv");
} finally {
loading.close();
}
};
return (_ctx, _cache) => {
const _component_el_descriptions_item = vue.resolveComponent("el-descriptions-item");
const _component_el_descriptions = vue.resolveComponent("el-descriptions");
const _component_el_button = vue.resolveComponent("el-button");
const _component_el_image = vue.resolveComponent("el-image");
const _component_el_table_column = vue.resolveComponent("el-table-column");
const _component_el_table = vue.resolveComponent("el-table");
const _component_el_dialog = vue.resolveComponent("el-dialog");
return vue.openBlock(), vue.createBlock(_component_el_dialog, {
title: "",
"append-to-body": "",
modelValue: dialogVisible.value,
"onUpdate:modelValue": _cache[2] || (_cache[2] = ($event) => dialogVisible.value = $event),
top: "5vh",
width: "80%"
}, {
footer: vue.withCtx(() => [
vue.createElementVNode("div", _hoisted_2, [
vue.createVNode(_component_el_button, {
onClick: _cache[0] || (_cache[0] = ($event) => dialogVisible.value = false)
}, {
default: vue.withCtx(() => _cache[7] || (_cache[7] = [
vue.createTextVNode("取 消")
])),
_: 1
}),
vue.createVNode(_component_el_button, {
type: "primary",
onClick: _cache[1] || (_cache[1] = ($event) => dialogVisible.value = false)
}, {
default: vue.withCtx(() => _cache[8] || (_cache[8] = [
vue.createTextVNode("确 定")
])),
_: 1
})
])
]),
default: vue.withCtx(() => [
vue.createVNode(_component_el_descriptions, {
title: "该主页用户信息",
direction: "vertical",
border: "",
column: 8
}, {
default: vue.withCtx(() => [
vue.createVNode(_component_el_descriptions_item, { label: "用户名" }, {
default: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(userInfo.value.nickname || "--"), 1)
]),
_: 1
}),
vue.createVNode(_component_el_descriptions_item, { label: "抖音号" }, {
default: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(userInfo.value.unique_id || "--"), 1)
]),
_: 1
}),
vue.createVNode(_component_el_descriptions_item, { label: "IP归属地" }, {
default: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(userInfo.value.ip_location || "--"), 1)
]),
_: 1
}),
vue.createVNode(_component_el_descriptions_item, { label: "粉丝量" }, {
default: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(vue.unref(formatNumber)(userInfo.value.mplatform_followers_count) || "--"), 1)
]),
_: 1
}),
vue.createVNode(_component_el_descriptions_item, { label: "获赞量" }, {
default: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(vue.unref(formatNumber)(userInfo.value.total_favorited) || "--"), 1)
]),
_: 1
}),
vue.createVNode(_component_el_descriptions_item, { label: "作品数" }, {
default: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(vue.unref(formatNumber)(userInfo.value.aweme_count) || "--"), 1)
]),
_: 1
}),
vue.createVNode(_component_el_descriptions_item, { label: "已加载视频数" }, {
default: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(vue.unref(formatNumber)(videoCount.value)), 1)
]),
_: 1
}),
vue.createVNode(_component_el_descriptions_item, { label: "已加载图文数" }, {
default: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(vue.unref(formatNumber)(imageCount.value)), 1)
]),
_: 1
}),
vue.createVNode(_component_el_descriptions_item, {
label: "简介",
span: 8
}, {
default: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(userInfo.value.signature || "--"), 1)
]),
_: 1
})
]),
_: 1
}),
vue.createElementVNode("div", _hoisted_1, [
vue.createVNode(_component_el_button, {
type: "primary",
onClick: handleDownloadAllVideos
}, {
default: vue.withCtx(() => _cache[3] || (_cache[3] = [
vue.createTextVNode("下载全部视频")
])),
_: 1
}),
vue.createVNode(_component_el_button, {
type: "primary",
onClick: handleDownloadAllImages
}, {
default: vue.withCtx(() => _cache[4] || (_cache[4] = [
vue.createTextVNode("下载全部图文")
])),
_: 1
}),
vue.createVNode(_component_el_button, {
type: "primary",
onClick: handleExport
}, {
default: vue.withCtx(() => _cache[5] || (_cache[5] = [
vue.createTextVNode("导出全部数据")
])),
_: 1
})
]),
vue.createVNode(_component_el_table, {
data: allAwemeList.value,
border: "",
style: { "width": "100%" },
height: "480"
}, {
default: vue.withCtx(() => [
vue.createVNode(_component_el_table_column, {
prop: "cover",
label: "封面",
width: "100",
align: "center"
}, {
default: vue.withCtx((scope) => [
vue.createVNode(_component_el_image, {
style: { "width": "60px" },
src: scope.row.cover,
"preview-src-list": [scope.row.cover]
}, null, 8, ["src", "preview-src-list"])
]),
_: 1
}),
vue.createVNode(_component_el_table_column, {
prop: "awemeId",
label: "awemeId",
"show-overflow-tooltip": "",
width: "180",
align: "center"
}),
vue.createVNode(_component_el_table_column, {
prop: "caption",
label: "标题",
"show-overflow-tooltip": "",
"min-width": "180",
align: "center"
}),
vue.createVNode(_component_el_table_column, {
prop: "tag",
label: "Tag",
"show-overflow-tooltip": "",
"min-width": "120",
align: "center"
}),
vue.createVNode(_component_el_table_column, {
prop: "diggCount",
label: "点赞数",
width: "120",
align: "center"
}),
vue.createVNode(_component_el_table_column, {
prop: "collectCount",
label: "收藏数",
width: "120",
align: "center"
}),
vue.createVNode(_component_el_table_column, {
prop: "commentCount",
label: "评论数",
width: "120",
align: "center"
}),
vue.createVNode(_component_el_table_column, {
prop: "shareCount",
label: "分享数",
width: "120",
align: "center"
}),
vue.createVNode(_component_el_table_column, {
prop: "date",
label: "发布时间",
width: "160",
align: "center"
}),
vue.createVNode(_component_el_table_column, {
prop: "desc",
label: "描述",
"show-overflow-tooltip": "",
width: "180",
align: "center"
}),
vue.createVNode(_component_el_table_column, {
label: "操作",
width: "100",
align: "center"
}, {
default: vue.withCtx((scope) => [
vue.createVNode(_component_el_button, {
type: "primary",
onClick: ($event) => handleDownload(scope.row)
}, {
default: vue.withCtx(() => _cache[6] || (_cache[6] = [
vue.createTextVNode("下载")
])),
_: 2
}, 1032, ["onClick"])
]),
_: 1
})
]),
_: 1
}, 8, ["data"])
]),
_: 1
}, 8, ["modelValue"]);
};
}
};
const _sfc_main = {
__name: "App",
setup(__props) {
console.log("%c插件加载成功", "color:red;padding:2px 4px;");
const size = vue.ref("small");
return (_ctx, _cache) => {
const _component_el_config_provider = vue.resolveComponent("el-config-provider");
return vue.openBlock(), vue.createBlock(_component_el_config_provider, { size: size.value }, {
default: vue.withCtx(() => [
vue.createVNode(_sfc_main$1)
]),
_: 1
}, 8, ["size"]);
};
}
};
vue.createApp(_sfc_main).use(ElementPlus).mount(
(() => {
const app = document.createElement("div");
app.id = "app";
document.body.append(app);
return app;
})()
);
})(Vue, ElementPlus, Jszip);