// ==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(? { 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);