Greasy Fork

Greasy Fork is available in English.

Douyin User Video Downloader

Extract video, audio, links and metadata from Douyin user profiles

当前为 2025-03-03 提交的版本,查看 最新版本

// ==UserScript==
// @name         Douyin User Video Downloader
// @namespace    http://tampermonkey.net/
// @version      1.2
// @description  Extract video, audio, links and metadata from Douyin user profiles
// @author       CaoCuong2404
// @match        https://www.douyin.com/user/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=douyin.com
// @grant        none
// @run-at       document-end
// ==/UserScript==

(function () {
  "use strict";

  // Add Tailwind CSS
  const tailwindCDN = document.createElement("script");
  tailwindCDN.src = "https://cdn.tailwindcss.com";
  document.head.appendChild(tailwindCDN);

  // Global state
  const state = {
    videos: [],
    selectedVideos: new Set(),
    isFetching: false,
    fetchedCount: 0,
    totalFound: 0,
  };

  function createMainUI() {
    const container = document.createElement("div");
    container.className = "fixed top-4 right-4 w-[900px] bg-white rounded-lg shadow-lg p-4 z-50 max-h-[90vh] flex flex-col";
    container.id = "douyin-downloader";

    container.innerHTML = `
      <div class="flex items-center justify-between mb-4">
        <div class="flex items-center space-x-2">
          <img src="https://www.douyin.com/favicon.ico" class="w-6 h-6" alt="Douyin">
          <h2 class="text-xl font-bold text-gray-800">Douyin Downloader</h2>
        </div>
        <div id="fetch-status" class="text-sm text-gray-500"></div>
      </div>

      <div class="border rounded-lg flex-1 flex flex-col overflow-hidden">
        <div class="p-4 border-b bg-gray-50 flex items-center justify-between">
          <div class="flex items-center space-x-4">
            <div class="flex items-center space-x-2">
              <input type="checkbox" id="select-all" class="rounded text-[#FE2C55]">
              <label for="select-all" class="text-sm font-medium text-gray-700">
                Select All (<span id="selected-count">0</span>/<span id="total-count">0</span>)
              </label>
            </div>
            
            <div class="h-4 border-l border-gray-300"></div>
            
            <div class="flex items-center space-x-2" id="action-buttons">
              <div class="relative inline-block text-left" id="download-dropdown">
                <button disabled id="download-btn" class="px-3 py-1.5 text-sm font-medium text-white bg-[#FE2C55] rounded-md shadow-sm hover:bg-[#fe2c55]/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#FE2C55] disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center">
                  Download
                  <svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
                  </svg>
                </button>
                <div class="hidden absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-50" id="dropdown-menu">
                  <div class="py-1">
                    <button class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" data-action="audio">
                      Download Audios (MP3)
                    </button>
                    <button class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" data-action="video">
                      Download Videos (MP4)
                    </button>
                    <button class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" data-action="json">
                      Download Metadata (JSON)
                    </button>
                    <button class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" data-action="txt">
                      Download Links (TXT)
                    </button>
                  </div>
                </div>
              </div>
            </div>
          </div>
          
          <button id="fetch-videos" class="px-3 py-1.5 text-sm font-medium text-white bg-[#FE2C55] rounded-md shadow-sm hover:bg-[#fe2c55]/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#FE2C55] inline-flex items-center">
            <span>Fetch Videos</span>
          </button>
        </div>
        
        <div class="overflow-auto flex-1">
          <table class="min-w-full divide-y divide-gray-200">
            <thead class="bg-gray-50 sticky top-0">
              <tr>
                <th scope="col" class="w-12 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                  Select
                </th>
                <th scope="col" class="w-16 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                  No.
                </th>
                <th scope="col" class="w-16 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                  Cover
                </th>
                <th scope="col" class="w-[300px] px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                  Title
                </th>
                <th scope="col" class="w-32 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                  Date
                </th>
                <th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                  Actions
                </th>
              </tr>
            </thead>
            <tbody id="videos-table-body" class="bg-white divide-y divide-gray-200">
              <!-- Videos will be inserted here -->
            </tbody>
          </table>
        </div>
      </div>
    `;

    return container;
  }

  function createVideoRow(video, index) {
    const row = document.createElement("tr");
    row.className = "hover:bg-gray-50";

    const date = new Date(video.createTime);
    const formattedDate = date.toLocaleDateString(undefined, {
      year: "numeric",
      month: "short",
      day: "numeric",
    });

    row.innerHTML = `
      <td class="px-4 py-4 whitespace-nowrap">
        <input type="checkbox" data-video-id="${video.id}" class="video-checkbox rounded text-[#FE2C55]">
      </td>
      <td class="px-4 py-4 whitespace-nowrap text-sm text-gray-500">
        ${index + 1}
      </td>
      <td class="px-4 py-4 whitespace-nowrap">
        <div class="w-12 h-12 rounded-lg overflow-hidden">
          <img src="${video.dynamicCoverUrl || video.coverUrl}" class="w-full h-full object-cover" alt="${video.title}">
        </div>
      </td>
      <td class="px-4 py-4 whitespace-nowrap">
        <div class="text-sm text-gray-900 font-medium truncate max-w-[300px]" title="${video.title}">
          ${video.title}
        </div>
      </td>
      <td class="px-4 py-4 whitespace-nowrap text-sm text-gray-500">
        ${formattedDate}
      </td>
      <td class="px-4 py-4 whitespace-nowrap text-sm">
        <div class="flex items-center space-x-2">
          <a href="${video.videoUrl}" target="_blank" class="text-[#FE2C55] hover:text-[#fe2c55]/90">
            Video
          </a>
          ${
            video.audioUrl
              ? `
            <span class="text-gray-300">|</span>
            <a href="${video.audioUrl}" target="_blank" class="text-[#FE2C55] hover:text-[#fe2c55]/90">
              Audio
            </a>
          `
              : ""
          }
        </div>
      </td>
    `;

    return row;
  }

  function updateUI() {
    const selectedCount = state.selectedVideos.size;
    const totalCount = state.videos.length;

    // Update counts
    document.getElementById("selected-count").textContent = selectedCount;
    document.getElementById("total-count").textContent = totalCount;

    // Update select all checkbox
    const selectAllCheckbox = document.getElementById("select-all");
    selectAllCheckbox.checked = selectedCount === totalCount && totalCount > 0;

    // Update download button
    const downloadBtn = document.getElementById("download-btn");
    downloadBtn.disabled = selectedCount === 0;
  }

  function setupEventListeners() {
    // Fetch videos button
    document.getElementById("fetch-videos").addEventListener("click", async () => {
      if (state.isFetching) return;

      state.isFetching = true;
      state.fetchedCount = 0;
      state.videos = [];
      state.selectedVideos.clear();

      const button = document.getElementById("fetch-videos");
      const statusEl = document.getElementById("fetch-status");
      const tableBody = document.getElementById("videos-table-body");
      tableBody.innerHTML = "";

      button.disabled = true;
      button.innerHTML = `
        <svg class="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24">
          <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
          <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
        </svg>
        Fetching...
      `;

      try {
        const downloader = new DouyinDownloader();
        await downloader.fetchAllVideos((newVideos) => {
          // Sort new videos by date (latest first)
          newVideos.sort((a, b) => new Date(b.createTime) - new Date(a.createTime));

          // Add new videos to state
          state.videos.push(...newVideos);
          state.fetchedCount += newVideos.length;

          // Update table
          state.videos.forEach((video, index) => {
            const existingRow = document.querySelector(`[data-video-id="${video.id}"]`)?.closest("tr");
            if (!existingRow) {
              tableBody.appendChild(createVideoRow(video, index));
            }
          });

          // Update status
          statusEl.textContent = `Fetched ${state.fetchedCount} videos`;
          updateUI();
        });

        setupTableEventListeners();
      } catch (error) {
        console.error("Error fetching videos:", error);
        statusEl.textContent = "Error: " + error.message;
      } finally {
        state.isFetching = false;
        button.disabled = false;
        button.innerHTML = "<span>Fetch Videos</span>";
      }
    });

    // Download dropdown
    const downloadBtn = document.getElementById("download-btn");
    const dropdownMenu = document.getElementById("dropdown-menu");

    downloadBtn.addEventListener("click", () => {
      dropdownMenu.classList.toggle("hidden");
    });

    // Close dropdown when clicking outside
    document.addEventListener("click", (e) => {
      if (!downloadBtn.contains(e.target)) {
        dropdownMenu.classList.add("hidden");
      }
    });

    // Download actions
    dropdownMenu.addEventListener("click", (e) => {
      const action = e.target.dataset.action;
      if (!action) return;

      const selectedVideos = state.videos.filter((v) => state.selectedVideos.has(v.id));

      switch (action) {
        case "audio":
          selectedVideos.forEach((video) => {
            if (video.audioUrl) window.open(video.audioUrl, "_blank");
          });
          break;
        case "video":
          selectedVideos.forEach((video) => {
            if (video.videoUrl) window.open(video.videoUrl, "_blank");
          });
          break;
        case "json":
          FileHandler.saveVideoUrls(selectedVideos, { downloadJson: true, downloadTxt: false });
          break;
        case "txt":
          FileHandler.saveVideoUrls(selectedVideos, { downloadJson: false, downloadTxt: true });
          break;
      }

      dropdownMenu.classList.add("hidden");
    });
  }

  function setupTableEventListeners() {
    // Select all checkbox
    document.getElementById("select-all").addEventListener("change", (e) => {
      const checkboxes = document.querySelectorAll(".video-checkbox");
      checkboxes.forEach((checkbox) => {
        checkbox.checked = e.target.checked;
        const videoId = checkbox.dataset.videoId;
        if (e.target.checked) {
          state.selectedVideos.add(videoId);
        } else {
          state.selectedVideos.delete(videoId);
        }
      });
      updateUI();
    });

    // Individual video checkboxes
    document.querySelectorAll(".video-checkbox").forEach((checkbox) => {
      checkbox.addEventListener("change", (e) => {
        const videoId = e.target.dataset.videoId;
        if (e.target.checked) {
          state.selectedVideos.add(videoId);
        } else {
          state.selectedVideos.delete(videoId);
        }
        updateUI();
      });
    });
  }

  // Configuration
  const CONFIG = {
    API_BASE_URL: "https://www.douyin.com/aweme/v1/web/aweme/post/",
    DEFAULT_HEADERS: {
      accept: "application/json, text/plain, */*",
      "accept-language": "vi",
      "sec-ch-ua": '"Not?A_Brand";v="8", "Chromium";v="118", "Microsoft Edge";v="118"',
      "sec-ch-ua-mobile": "?0",
      "sec-ch-ua-platform": '"Windows"',
      "sec-fetch-dest": "empty",
      "sec-fetch-mode": "cors",
      "sec-fetch-site": "same-origin",
      "user-agent":
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36 Edg/118.0.0.0",
    },
    RETRY_DELAY_MS: 2000,
    MAX_RETRIES: 5,
    REQUEST_DELAY_MS: 1000,
  };

  // Utility functions
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

  const retryWithDelay = async (fn, retries = CONFIG.MAX_RETRIES) => {
    let lastError;
    for (let i = 0; i < retries; i++) {
      try {
        return await fn();
      } catch (error) {
        lastError = error;
        console.log(`Attempt ${i + 1} failed:`, error);
        await sleep(CONFIG.RETRY_DELAY_MS);
      }
    }
    throw lastError;
  };

  // API Client
  class DouyinApiClient {
    constructor(secUserId) {
      this.secUserId = secUserId;
    }

    async fetchVideos(maxCursor) {
      const url = new URL(CONFIG.API_BASE_URL);
      const params = {
        device_platform: "webapp",
        aid: "6383",
        channel: "channel_pc_web",
        sec_user_id: this.secUserId,
        max_cursor: maxCursor,
        count: "20",
        version_code: "170400",
        version_name: "17.4.0",
      };

      Object.entries(params).forEach(([key, value]) => url.searchParams.append(key, value));

      const response = await fetch(url, {
        headers: {
          ...CONFIG.DEFAULT_HEADERS,
          referrer: `https://www.douyin.com/user/${this.secUserId}`,
        },
        credentials: "include",
      });

      if (!response.ok) {
        throw new Error(`HTTP Error: ${response.status}`);
      }

      return response.json();
    }
  }

  // Data Processing
  class VideoDataProcessor {
    static extractVideoMetadata(video) {
      if (!video) return null;

      // Initialize the metadata object
      const metadata = {
        id: video.aweme_id || "",
        desc: video.desc || "",
        title: video.desc || "", // Using desc as the title since title field isn't directly available
        createTime: video.create_time ? new Date(video.create_time * 1000).toISOString() : "",
        videoUrl: "",
        audioUrl: "",
        coverUrl: "",
        dynamicCoverUrl: "",
      };

      // Extract video URL
      if (video.video?.play_addr) {
        metadata.videoUrl = video.video.play_addr.url_list[0];
        if (metadata.videoUrl && !metadata.videoUrl.startsWith("https")) {
          metadata.videoUrl = metadata.videoUrl.replace("http", "https");
        }
      } else if (video.video?.download_addr) {
        metadata.videoUrl = video.video.download_addr.url_list[0];
        if (metadata.videoUrl && !metadata.videoUrl.startsWith("https")) {
          metadata.videoUrl = metadata.videoUrl.replace("http", "https");
        }
      }

      // Extract audio URL
      if (video.music?.play_url) {
        metadata.audioUrl = video.music.play_url.url_list[0];
      }

      // Extract cover URL (static thumbnail)
      if (video.video?.cover) {
        metadata.coverUrl = video.video.cover.url_list[0];
      } else if (video.cover) {
        metadata.coverUrl = video.cover.url_list[0];
      }

      // Extract dynamic cover URL (animated thumbnail)
      if (video.video?.dynamic_cover) {
        metadata.dynamicCoverUrl = video.video.dynamic_cover.url_list[0];
      } else if (video.dynamic_cover) {
        metadata.dynamicCoverUrl = video.dynamic_cover.url_list[0];
      }

      return metadata;
    }

    static processVideoData(data) {
      if (!data?.aweme_list) {
        return { videoData: [], hasMore: false, maxCursor: 0 };
      }

      const videoData = data.aweme_list.map((video) => this.extractVideoMetadata(video)).filter((item) => item && item.videoUrl);

      return {
        videoData,
        hasMore: data.has_more,
        maxCursor: data.max_cursor,
      };
    }
  }

  // File Handler
  class FileHandler {
    static saveVideoUrls(videoData, options = { downloadJson: true, downloadTxt: true }) {
      if (!videoData || videoData.length === 0) {
        console.warn("No video data to save");
        return { savedCount: 0 };
      }

      const now = new Date();
      const timestamp = now.toISOString().replace(/[:.]/g, "-");
      let savedCount = 0;

      // Save complete JSON data if option is enabled
      if (options.downloadJson) {
        const jsonContent = JSON.stringify(videoData, null, 2);
        const jsonBlob = new Blob([jsonContent], { type: "application/json" });
        const jsonUrl = URL.createObjectURL(jsonBlob);

        const jsonLink = document.createElement("a");
        jsonLink.href = jsonUrl;
        jsonLink.download = `douyin-video-data-${timestamp}.json`;
        jsonLink.style.display = "none";
        document.body.appendChild(jsonLink);
        jsonLink.click();
        document.body.removeChild(jsonLink);

        console.log(`Saved ${videoData.length} videos with metadata to JSON file`);
      }

      // Save plain URLs list if option is enabled
      if (options.downloadTxt) {
        // Create a list of video URLs
        const urlList = videoData.map((video) => video.videoUrl).join("\n");
        const txtBlob = new Blob([urlList], { type: "text/plain" });
        const txtUrl = URL.createObjectURL(txtBlob);

        const txtLink = document.createElement("a");
        txtLink.href = txtUrl;
        txtLink.download = `douyin-video-links-${timestamp}.txt`;
        txtLink.style.display = "none";
        document.body.appendChild(txtLink);
        txtLink.click();
        document.body.removeChild(txtLink);

        console.log(`Saved ${videoData.length} video URLs to text file`);
      }

      savedCount = videoData.length;
      return { savedCount };
    }
  }

  // Main Downloader
  class DouyinDownloader {
    constructor() {
      this.validateEnvironment();
      const secUserId = this.extractSecUserId();
      this.apiClient = new DouyinApiClient(secUserId);
    }

    validateEnvironment() {
      if (typeof window === "undefined" || !window.location) {
        throw new Error("Script must be run in a browser environment");
      }
    }

    extractSecUserId() {
      const secUserId = location.pathname.replace("/user/", "");
      if (!secUserId || location.pathname.indexOf("/user/") === -1) {
        throw new Error("Please run this script on a DouYin user profile page!");
      }
      return secUserId;
    }

    async fetchAllVideos(onProgress) {
      let hasMore = true;
      let maxCursor = 0;

      while (hasMore) {
        const data = await retryWithDelay(() => this.apiClient.fetchVideos(maxCursor));
        const { videoData, hasMore: more, maxCursor: newCursor } = VideoDataProcessor.processVideoData(data);

        if (onProgress) {
          onProgress(videoData);
        }

        hasMore = more;
        maxCursor = newCursor;
        await sleep(CONFIG.REQUEST_DELAY_MS);
      }
    }
  }

  // Initialize the UI
  function initializeUI() {
    const container = createMainUI();
    document.body.appendChild(container);
    setupEventListeners();
  }

  // Start the script
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", initializeUI);
  } else {
    initializeUI();
  }
})();