Douyin User Video Downloader

Extract video links and metadata from Douyin user profiles

// ==UserScript==
// @name         Douyin User Video Downloader
// @namespace    https://github.com/CaoCuong2404
// @version      1.6
// @description  Extract video 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,
    isDialogOpen: false,
  };

  function createMainUI() {
    // Create backdrop
    const backdrop = document.createElement("div");
    backdrop.className = "fixed inset-0 bg-black bg-opacity-50 z-[9999] hidden";
    backdrop.id = "douyin-downloader-backdrop";

    // Create dialog container
    const container = document.createElement("div");
    container.className =
      "fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-[900px] bg-white rounded-lg shadow-xl z-[10000] hidden";
    container.id = "douyin-downloader";

    container.innerHTML = `
      <div class="flex flex-col max-h-[90vh]">
        <div class="flex items-center justify-between p-4 border-b">
          <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>
          <button id="close-dialog" class="text-gray-400 hover:text-gray-600">
            <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
            </svg>
          </button>
        </div>

        <div class="p-4 flex-1 overflow-hidden flex flex-col min-h-[500px]">
          <div id="fetch-status" class="text-sm text-gray-500 mb-4"></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>
        </div>
      </div>
    `;

    document.body.appendChild(backdrop);
    document.body.appendChild(container);

    return { backdrop, container };
  }

  async function addDownloadButton() {
    try {
      // Wait initial 2s for UI to stabilize and translations to complete
      await sleep(2000);

      // Try to find the element multiple times
      let attempts = 3;
      let tabCountElement = null;

      while (attempts > 0 && !tabCountElement) {
        try {
          tabCountElement = await waitForElement('[data-e2e="user-tab-count"]', 10000); // 10s timeout per attempt
          break;
        } catch (err) {
          attempts--;
          if (attempts > 0) {
            console.log("Retrying to find tab count element...");
            // Wait between attempts
            await sleep(1000);
          } else {
            throw new Error(
              "Could not find video count element after multiple attempts. This could be due to UI changes or page translation.",
            );
          }
        }
      }

      // Extra check for parent element stability
      const parentElement = tabCountElement.parentNode;
      if (!parentElement || !parentElement.isConnected) {
        throw new Error("Parent element of video count is not stable");
      }

      const downloadButton = document.createElement("button");
      downloadButton.className = "ml-2 text-[#FE2C55] hover:text-[#fe2c55]/90 transition-colors";
      downloadButton.innerHTML = `
        <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
        </svg>
      `;
      downloadButton.title = "Download all videos";

      // Insert after the count with stability check
      if (tabCountElement.nextSibling) {
        parentElement.insertBefore(downloadButton, tabCountElement.nextSibling);
      } else {
        parentElement.appendChild(downloadButton);
      }

      // Add click handler
      downloadButton.addEventListener("click", showDialog);

      // Monitor for potential DOM changes that could affect the button
      const observer = new MutationObserver((mutations) => {
        if (!downloadButton.isConnected) {
          // Button was removed, try to re-add it
          if (tabCountElement.isConnected) {
            if (tabCountElement.nextSibling) {
              parentElement.insertBefore(downloadButton, tabCountElement.nextSibling);
            } else {
              parentElement.appendChild(downloadButton);
            }
          }
        }
      });

      observer.observe(parentElement, {
        childList: true,
        subtree: true,
      });
    } catch (error) {
      console.error("Failed to add download button:", error);
    }
  }

  function showDialog() {
    const backdrop = document.getElementById("douyin-downloader-backdrop");
    const dialog = document.getElementById("douyin-downloader");

    backdrop.classList.remove("hidden");
    dialog.classList.remove("hidden");

    // Add animation classes
    dialog.classList.add("animate-fade-in");
    backdrop.classList.add("animate-fade-in");

    state.isDialogOpen = true;
  }

  function hideDialog() {
    const backdrop = document.getElementById("douyin-downloader-backdrop");
    const dialog = document.getElementById("douyin-downloader");

    backdrop.classList.add("hidden");
    dialog.classList.add("hidden");

    state.isDialogOpen = false;
  }

  function setupDialogEventListeners() {
    // Close button
    document.getElementById("close-dialog")?.addEventListener("click", hideDialog);

    // Close on backdrop click
    document.getElementById("douyin-downloader-backdrop")?.addEventListener("click", hideDialog);

    // Prevent dialog close when clicking inside
    document.getElementById("douyin-downloader")?.addEventListener("click", (e) => {
      e.stopPropagation();
    });

    // Close on Escape key
    document.addEventListener("keydown", (e) => {
      if (e.key === "Escape" && state.isDialogOpen) {
        hideDialog();
      }
    });
  }

  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", async (e) => {
      const action = e.target.dataset.action;
      if (!action) return;

      const selectedVideos = state.videos.filter((v) => state.selectedVideos.has(v.id));
      if (selectedVideos.length === 0) return;

      // Hide dropdown
      dropdownMenu.classList.add("hidden");

      switch (action) {
        case "audio":
          await downloadFiles(selectedVideos, "audio");
          break;
        case "video":
          await downloadFiles(selectedVideos, "video");
          break;
        case "json":
          FileHandler.saveVideoUrls(selectedVideos, { downloadJson: true, downloadTxt: false });
          break;
        case "txt":
          FileHandler.saveVideoUrls(selectedVideos, { downloadJson: false, downloadTxt: true });
          break;
      }
    });
  }

  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 waitForElement = (selector, timeout = 30000, interval = 100) => {
    return new Promise((resolve, reject) => {
      // Check if element already exists
      const element = document.querySelector(selector);
      if (element) {
        resolve(element);
        return;
      }

      // Set up the timeout
      const timeoutId = setTimeout(() => {
        observer.disconnect();
        clearInterval(checkInterval);
        reject(new Error(`Timeout waiting for element: ${selector}`));
      }, timeout);

      // Set up the mutation observer
      const observer = new MutationObserver((mutations, obs) => {
        const element = document.querySelector(selector);
        if (element) {
          obs.disconnect();
          clearInterval(checkInterval);
          clearTimeout(timeoutId);
          resolve(element);
        }
      });

      // Start observing
      observer.observe(document.body, {
        childList: true,
        subtree: true,
      });

      // Also poll periodically as a backup
      const checkInterval = setInterval(() => {
        const element = document.querySelector(selector);
        if (element) {
          observer.disconnect();
          clearInterval(checkInterval);
          clearTimeout(timeoutId);
          resolve(element);
        }
      }, interval);
    });
  };

  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
  async function initializeUI() {
    // Add custom styles for animations
    const style = document.createElement("style");
    style.textContent = `
      @keyframes fadeIn {
        from { opacity: 0; }
        to { opacity: 1; }
      }
      .animate-fade-in {
        animation: fadeIn 0.2s ease-out;
      }
    `;
    document.head.appendChild(style);

    // Create UI elements (hidden initially)
    createMainUI();

    // Add download button to profile
    await addDownloadButton();

    // Setup all event listeners
    setupEventListeners();
    setupTableEventListeners();
    setupDialogEventListeners();
  }

  // Start the script
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", () => {
      initializeUI().catch((error) => {
        console.error("Failed to initialize UI:", error);
      });
    });
  } else {
    initializeUI().catch((error) => {
      console.error("Failed to initialize UI:", error);
    });
  }

  async function downloadFile(url, filename) {
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);

      const blob = await response.blob();
      const blobUrl = URL.createObjectURL(blob);

      const link = document.createElement("a");
      link.href = blobUrl;
      link.download = filename;
      link.style.display = "none";
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);

      // Clean up
      setTimeout(() => URL.revokeObjectURL(blobUrl), 100);

      return true;
    } catch (error) {
      console.error(`Failed to download ${filename}:`, error);
      return false;
    }
  }

  async function downloadFiles(files, type = "video") {
    const statusEl = document.getElementById("fetch-status");
    const total = files.length;
    let successful = 0;
    let failed = 0;

    for (let i = 0; i < files.length; i++) {
      const file = files[i];
      const url = type === "video" ? file.videoUrl : file.audioUrl;
      if (!url) {
        failed++;
        continue;
      }

      // Update status
      statusEl.textContent = `Downloading ${type} ${i + 1}/${total}...`;

      // Generate filename
      const timestamp = new Date(file.createTime).toISOString().split("T")[0];
      const filename = `douyin_${type}_${timestamp}_${file.id}.${type === "video" ? "mp4" : "mp3"}`;

      // Download file
      const success = await downloadFile(url, filename);
      if (success) {
        successful++;
      } else {
        failed++;
      }

      // Small delay between downloads to prevent browser blocking
      await sleep(500);
    }

    // Final status update
    statusEl.textContent = `Download complete: ${successful} successful, ${failed} failed`;
    setTimeout(() => {
      statusEl.textContent = "";
    }, 5000);
  }
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址