您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Greasy Fork is available in English.
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); } })();