Greasy Fork

Greasy Fork is available in English.

Douyin User Video Downloader

Extract video links and metadata from Douyin user profiles

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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