Created
March 3, 2025 08:02
-
-
Save caocuong2404/d8d76a4b7edba6d1d23ee13909ea7c08 to your computer and use it in GitHub Desktop.
Download videos and metadata from Douyin user profiles
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Douyin Video Metadata Downloader | |
// @namespace http://tampermonkey.net/ | |
// @version 1.0 | |
// @description Download videos 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 | |
// ==/UserScript== | |
(function() { | |
'use strict'; | |
// Configuration | |
const CONFIG = { | |
API_BASE_URL: "https://www.douyin.com/aweme/v1/web/aweme/post/", | |
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, | |
}; | |
function addUI() { | |
const container = document.createElement('div'); | |
container.style.position = 'fixed'; | |
container.style.top = '80px'; | |
container.style.right = '20px'; | |
container.style.zIndex = '9999'; | |
container.style.backgroundColor = 'white'; | |
container.style.border = '1px solid #ccc'; | |
container.style.borderRadius = '5px'; | |
container.style.padding = '10px'; | |
container.style.boxShadow = '0 0 10px rgba(0,0,0,0.1)'; | |
container.style.width = '250px'; | |
const title = document.createElement('h3'); | |
title.textContent = 'Douyin Downloader'; | |
title.style.margin = '0 0 10px 0'; | |
title.style.padding = '0 0 5px 0'; | |
title.style.borderBottom = '1px solid #eee'; | |
container.appendChild(title); | |
// Add download options | |
const optionsDiv = document.createElement('div'); | |
optionsDiv.style.margin = '10px 0'; | |
// JSON Metadata option | |
const jsonOption = document.createElement('div'); | |
const jsonCheckbox = document.createElement('input'); | |
jsonCheckbox.type = 'checkbox'; | |
jsonCheckbox.id = 'download-json'; | |
jsonCheckbox.checked = true; | |
const jsonLabel = document.createElement('label'); | |
jsonLabel.htmlFor = 'download-json'; | |
jsonLabel.textContent = 'Download JSON metadata'; | |
jsonLabel.style.marginLeft = '5px'; | |
jsonOption.appendChild(jsonCheckbox); | |
jsonOption.appendChild(jsonLabel); | |
// Text Links option | |
const txtOption = document.createElement('div'); | |
const txtCheckbox = document.createElement('input'); | |
txtCheckbox.type = 'checkbox'; | |
txtCheckbox.id = 'download-txt'; | |
txtCheckbox.checked = true; | |
const txtLabel = document.createElement('label'); | |
txtLabel.htmlFor = 'download-txt'; | |
txtLabel.textContent = 'Download video links (TXT)'; | |
txtLabel.style.marginLeft = '5px'; | |
txtOption.appendChild(txtCheckbox); | |
txtOption.appendChild(txtLabel); | |
optionsDiv.appendChild(jsonOption); | |
optionsDiv.appendChild(txtOption); | |
container.appendChild(optionsDiv); | |
const downloadBtn = document.createElement('button'); | |
downloadBtn.textContent = 'Download All Videos'; | |
downloadBtn.style.width = '100%'; | |
downloadBtn.style.padding = '8px'; | |
downloadBtn.style.backgroundColor = '#ff0050'; | |
downloadBtn.style.color = 'white'; | |
downloadBtn.style.border = 'none'; | |
downloadBtn.style.borderRadius = '4px'; | |
downloadBtn.style.cursor = 'pointer'; | |
downloadBtn.style.marginBottom = '10px'; | |
container.appendChild(downloadBtn); | |
const statusElement = document.createElement('div'); | |
statusElement.id = 'downloader-status'; | |
statusElement.style.fontSize = '14px'; | |
statusElement.style.marginTop = '10px'; | |
container.appendChild(statusElement); | |
document.body.appendChild(container); | |
downloadBtn.addEventListener('click', async () => { | |
const downloadJson = document.getElementById('download-json').checked; | |
const downloadTxt = document.getElementById('download-txt').checked; | |
if (!downloadJson && !downloadTxt) { | |
statusElement.textContent = 'Please select at least one download option'; | |
return; | |
} | |
const downloader = new DouyinDownloader(statusElement); | |
downloader.downloadOptions = { downloadJson, downloadTxt }; | |
await downloader.downloadAllVideos(); | |
}); | |
} | |
// Utility functions | |
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); | |
const retryWithDelay = async (fn, retries = CONFIG.MAX_RETRIES) => { | |
let lastError; | |
for (let i = 0; i < retries; i++) { | |
try { | |
return await fn(); | |
} catch (error) { | |
lastError = error; | |
console.log(`Attempt ${i + 1} failed:`, error); | |
await sleep(CONFIG.RETRY_DELAY_MS); | |
} | |
} | |
throw lastError; | |
}; | |
// API Client | |
class DouyinApiClient { | |
constructor(secUserId) { | |
this.secUserId = secUserId; | |
} | |
async fetchVideos(maxCursor) { | |
const url = new URL(CONFIG.API_BASE_URL); | |
const params = { | |
device_platform: "webapp", | |
aid: "6383", | |
channel: "channel_pc_web", | |
sec_user_id: this.secUserId, | |
max_cursor: maxCursor, | |
count: "20", | |
version_code: "170400", | |
version_name: "17.4.0", | |
cookie_enabled: "true", | |
screen_width: "1920", | |
screen_height: "1080", | |
browser_language: "en-US", | |
browser_platform: "Win32", | |
browser_name: "Chrome", | |
browser_version: "118.0.0.0", | |
browser_online: "true", | |
tzName: "America/Los_Angeles", | |
cursor: maxCursor, | |
web_id: "7242155500523021835", | |
}; | |
Object.entries(params).forEach(([key, value]) => { | |
url.searchParams.append(key, value); | |
}); | |
const response = await fetch(url.toString(), { | |
headers: { | |
"User-Agent": CONFIG.USER_AGENT, | |
}, | |
method: "GET", | |
}); | |
if (!response.ok) { | |
throw new Error(`API response error: ${response.status}`); | |
} | |
return await response.json(); | |
} | |
} | |
class VideoDataProcessor { | |
static extractVideoMetadata(video) { | |
if (!video) return null; | |
// Extract required metadata fields | |
const id = video.aweme_id || ''; | |
const desc = video.desc || ''; | |
const title = desc; // Using description as title | |
// Format creation time as ISO date string | |
const createTime = video.create_time ? | |
new Date(video.create_time * 1000).toISOString() : ''; | |
// Extract video URL | |
let videoUrl = ''; | |
if (video.video && video.video.play_addr && | |
video.video.play_addr.url_list && | |
video.video.play_addr.url_list.length > 0) { | |
videoUrl = video.video.play_addr.url_list[0]; | |
// Convert HTTP to HTTPS if needed | |
if (videoUrl.startsWith('http:')) { | |
videoUrl = videoUrl.replace('http:', 'https:'); | |
} | |
} | |
// Extract audio URL | |
let audioUrl = ''; | |
if (video.music && video.music.play_url && | |
video.music.play_url.url_list && | |
video.music.play_url.url_list.length > 0) { | |
audioUrl = video.music.play_url.url_list[0]; | |
} | |
// Extract cover image URL | |
let coverUrl = ''; | |
if (video.video && video.video.cover && | |
video.video.cover.url_list && | |
video.video.cover.url_list.length > 0) { | |
coverUrl = video.video.cover.url_list[0]; | |
} | |
// Extract dynamic cover URL (animated) | |
let dynamicCoverUrl = ''; | |
if (video.video && video.video.dynamic_cover && | |
video.video.dynamic_cover.url_list && | |
video.video.dynamic_cover.url_list.length > 0) { | |
dynamicCoverUrl = video.video.dynamic_cover.url_list[0]; | |
} | |
return { | |
id, | |
desc, | |
title, | |
createTime, | |
videoUrl, | |
audioUrl, | |
coverUrl, | |
dynamicCoverUrl | |
}; | |
} | |
static processVideoData(data) { | |
// Check if we have valid data with the aweme_list property | |
if (!data || !data.aweme_list || !Array.isArray(data.aweme_list)) { | |
console.warn("Invalid video data format", data); | |
return []; | |
} | |
// Process each video to extract metadata | |
return data.aweme_list | |
.map(video => this.extractVideoMetadata(video)) | |
.filter(video => video && video.videoUrl); // Filter out videos without URLs | |
} | |
} | |
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 }; | |
} | |
} | |
class DouyinDownloader { | |
constructor(statusElement) { | |
this.statusElement = statusElement; | |
this.downloadOptions = { downloadJson: true, downloadTxt: true }; | |
} | |
validateEnvironment() { | |
// Check if we're on a Douyin user profile page | |
const url = window.location.href; | |
return url.includes('douyin.com/user/'); | |
} | |
extractSecUserId() { | |
const url = window.location.href; | |
const match = url.match(/user\/([^?/]+)/); | |
return match ? match[1] : null; | |
} | |
updateStatus(message) { | |
if (this.statusElement) { | |
this.statusElement.textContent = message; | |
} | |
console.log(message); | |
} | |
async downloadAllVideos() { | |
try { | |
if (!this.validateEnvironment()) { | |
this.updateStatus('This script only works on Douyin user profile pages'); | |
return; | |
} | |
const secUserId = this.extractSecUserId(); | |
if (!secUserId) { | |
this.updateStatus('Could not find user ID in URL'); | |
return; | |
} | |
this.updateStatus('Starting download process...'); | |
const client = new DouyinApiClient(secUserId); | |
let hasMore = true; | |
let maxCursor = 0; | |
let allVideos = []; | |
while (hasMore) { | |
this.updateStatus(`Fetching videos, cursor: ${maxCursor}...`); | |
const data = await retryWithDelay(async () => { | |
return await client.fetchVideos(maxCursor); | |
}); | |
const videos = VideoDataProcessor.processVideoData(data); | |
allVideos = allVideos.concat(videos); | |
this.updateStatus(`Found ${videos.length} videos (total: ${allVideos.length})`); | |
// Check if there are more videos to fetch | |
hasMore = data.has_more === 1; | |
maxCursor = data.max_cursor; | |
// Add a delay to avoid rate limiting | |
await sleep(CONFIG.REQUEST_DELAY_MS); | |
} | |
if (allVideos.length === 0) { | |
this.updateStatus('No videos found for this user'); | |
return; | |
} | |
this.updateStatus(`Processing ${allVideos.length} videos...`); | |
const result = FileHandler.saveVideoUrls(allVideos, this.downloadOptions); | |
this.updateStatus(`Download complete! Saved ${result.savedCount} videos`); | |
} catch (error) { | |
console.error('Download failed:', error); | |
this.updateStatus(`Error: ${error.message}`); | |
} | |
} | |
} | |
async function run() { | |
// Wait for the page to load fully | |
setTimeout(() => { | |
addUI(); | |
console.log('Douyin Video Downloader initialized'); | |
}, 2000); | |
} | |
// Initialize the script | |
run(); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment