// ==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 // @downloadURL https://update.greasyfork.icu/scripts/528620/Douyin%20User%20Video%20Downloader.user.js // @updateURL https://update.greasyfork.icu/scripts/528620/Douyin%20User%20Video%20Downloader.meta.js // ==/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 = `
Douyin

Douyin Downloader

Select No. Cover Title Date Actions
`; 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 = ` `; 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 = ` ${index + 1}
${video.title}
${video.title}
${formattedDate}
Video ${ video.audioUrl ? ` | Audio ` : "" }
`; 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 = ` 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 = "Fetch Videos"; } }); // 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); } })();