Greasy Fork

Greasy Fork is available in English.

抖音主页视频图文下载

用于抖音主页视频图文下载

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

// ==UserScript==
// @name         抖音主页视频图文下载
// @namespace    douyin-homepage-download
// @version      0.1.3
// @author       chrngfu
// @description  用于抖音主页视频图文下载
// @license      MIT
// @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/[email protected]/dist/vue.global.prod.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js
// @require      https://unpkg.com/[email protected]/dist/index.full.min.js
// @resource     element-plus/dist/index.css  https://cdn.jsdelivr.net/npm/[email protected]/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);