Douyin Video Metadata Downloader

Download videos and metadata from Douyin user profiles

目前为 2025-03-03 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Douyin Video Metadata Downloader
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.1
  5. // @description Download videos and metadata from Douyin user profiles
  6. // @author CaoCuong2404
  7. // @match https://www.douyin.com/user/*
  8. // @icon https://www.google.com/s2/favicons?sz=64&domain=douyin.com
  9. // @grant none
  10. // ==/UserScript==
  11.  
  12. (function() {
  13. 'use strict';
  14.  
  15. // Configuration
  16. const CONFIG = {
  17. API_BASE_URL: "https://www.douyin.com/aweme/v1/web/aweme/post/",
  18. USER_AGENT:
  19. "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",
  20. RETRY_DELAY_MS: 2000,
  21. MAX_RETRIES: 5,
  22. REQUEST_DELAY_MS: 1000,
  23. };
  24.  
  25. function addUI() {
  26. const container = document.createElement('div');
  27. container.style.position = 'fixed';
  28. container.style.top = '80px';
  29. container.style.right = '20px';
  30. container.style.zIndex = '9999';
  31. container.style.backgroundColor = 'white';
  32. container.style.border = '1px solid #ccc';
  33. container.style.borderRadius = '5px';
  34. container.style.padding = '10px';
  35. container.style.boxShadow = '0 0 10px rgba(0,0,0,0.1)';
  36. container.style.width = '250px';
  37.  
  38. const title = document.createElement('h3');
  39. title.textContent = 'Douyin Downloader';
  40. title.style.margin = '0 0 10px 0';
  41. title.style.padding = '0 0 5px 0';
  42. title.style.borderBottom = '1px solid #eee';
  43. container.appendChild(title);
  44.  
  45. // Add download options
  46. const optionsDiv = document.createElement('div');
  47. optionsDiv.style.margin = '10px 0';
  48. // JSON Metadata option
  49. const jsonOption = document.createElement('div');
  50. const jsonCheckbox = document.createElement('input');
  51. jsonCheckbox.type = 'checkbox';
  52. jsonCheckbox.id = 'download-json';
  53. jsonCheckbox.checked = true;
  54. const jsonLabel = document.createElement('label');
  55. jsonLabel.htmlFor = 'download-json';
  56. jsonLabel.textContent = 'Download JSON metadata';
  57. jsonLabel.style.marginLeft = '5px';
  58. jsonOption.appendChild(jsonCheckbox);
  59. jsonOption.appendChild(jsonLabel);
  60. // Text Links option
  61. const txtOption = document.createElement('div');
  62. const txtCheckbox = document.createElement('input');
  63. txtCheckbox.type = 'checkbox';
  64. txtCheckbox.id = 'download-txt';
  65. txtCheckbox.checked = true;
  66. const txtLabel = document.createElement('label');
  67. txtLabel.htmlFor = 'download-txt';
  68. txtLabel.textContent = 'Download video links (TXT)';
  69. txtLabel.style.marginLeft = '5px';
  70. txtOption.appendChild(txtCheckbox);
  71. txtOption.appendChild(txtLabel);
  72. optionsDiv.appendChild(jsonOption);
  73. optionsDiv.appendChild(txtOption);
  74. container.appendChild(optionsDiv);
  75.  
  76. const downloadBtn = document.createElement('button');
  77. downloadBtn.textContent = 'Download All Videos';
  78. downloadBtn.style.width = '100%';
  79. downloadBtn.style.padding = '8px';
  80. downloadBtn.style.backgroundColor = '#ff0050';
  81. downloadBtn.style.color = 'white';
  82. downloadBtn.style.border = 'none';
  83. downloadBtn.style.borderRadius = '4px';
  84. downloadBtn.style.cursor = 'pointer';
  85. downloadBtn.style.marginBottom = '10px';
  86. container.appendChild(downloadBtn);
  87.  
  88. const statusElement = document.createElement('div');
  89. statusElement.id = 'downloader-status';
  90. statusElement.style.fontSize = '14px';
  91. statusElement.style.marginTop = '10px';
  92. container.appendChild(statusElement);
  93.  
  94. document.body.appendChild(container);
  95.  
  96. downloadBtn.addEventListener('click', async () => {
  97. const downloadJson = document.getElementById('download-json').checked;
  98. const downloadTxt = document.getElementById('download-txt').checked;
  99. if (!downloadJson && !downloadTxt) {
  100. statusElement.textContent = 'Please select at least one download option';
  101. return;
  102. }
  103. const downloader = new DouyinDownloader(statusElement);
  104. downloader.downloadOptions = { downloadJson, downloadTxt };
  105. await downloader.downloadAllVideos();
  106. });
  107. }
  108.  
  109. // Utility functions
  110. const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
  111.  
  112. const retryWithDelay = async (fn, retries = CONFIG.MAX_RETRIES) => {
  113. let lastError;
  114. for (let i = 0; i < retries; i++) {
  115. try {
  116. return await fn();
  117. } catch (error) {
  118. lastError = error;
  119. console.log(`Attempt ${i + 1} failed:`, error);
  120. await sleep(CONFIG.RETRY_DELAY_MS);
  121. }
  122. }
  123. throw lastError;
  124. };
  125.  
  126. // API Client
  127. class DouyinApiClient {
  128. constructor(secUserId) {
  129. this.secUserId = secUserId;
  130. }
  131.  
  132. async fetchVideos(maxCursor) {
  133. const url = new URL(CONFIG.API_BASE_URL);
  134. const params = {
  135. device_platform: "webapp",
  136. aid: "6383",
  137. channel: "channel_pc_web",
  138. sec_user_id: this.secUserId,
  139. max_cursor: maxCursor,
  140. count: "20",
  141. version_code: "170400",
  142. version_name: "17.4.0",
  143. cookie_enabled: "true",
  144. screen_width: "1920",
  145. screen_height: "1080",
  146. browser_language: "en-US",
  147. browser_platform: "Win32",
  148. browser_name: "Chrome",
  149. browser_version: "118.0.0.0",
  150. browser_online: "true",
  151. tzName: "America/Los_Angeles",
  152. cursor: maxCursor,
  153. web_id: "7242155500523021835",
  154. };
  155.  
  156. Object.entries(params).forEach(([key, value]) => {
  157. url.searchParams.append(key, value);
  158. });
  159.  
  160. const response = await fetch(url.toString(), {
  161. headers: {
  162. "User-Agent": CONFIG.USER_AGENT,
  163. },
  164. method: "GET",
  165. });
  166.  
  167. if (!response.ok) {
  168. throw new Error(`API response error: ${response.status}`);
  169. }
  170.  
  171. return await response.json();
  172. }
  173. }
  174.  
  175. class VideoDataProcessor {
  176. static extractVideoMetadata(video) {
  177. if (!video) return null;
  178.  
  179. // Extract required metadata fields
  180. const id = video.aweme_id || '';
  181. const desc = video.desc || '';
  182. const title = desc; // Using description as title
  183. // Format creation time as ISO date string
  184. const createTime = video.create_time ?
  185. new Date(video.create_time * 1000).toISOString() : '';
  186. // Extract video URL
  187. let videoUrl = '';
  188. if (video.video && video.video.play_addr &&
  189. video.video.play_addr.url_list &&
  190. video.video.play_addr.url_list.length > 0) {
  191. videoUrl = video.video.play_addr.url_list[0];
  192. // Convert HTTP to HTTPS if needed
  193. if (videoUrl.startsWith('http:')) {
  194. videoUrl = videoUrl.replace('http:', 'https:');
  195. }
  196. }
  197. // Extract audio URL
  198. let audioUrl = '';
  199. if (video.music && video.music.play_url &&
  200. video.music.play_url.url_list &&
  201. video.music.play_url.url_list.length > 0) {
  202. audioUrl = video.music.play_url.url_list[0];
  203. }
  204. // Extract cover image URL
  205. let coverUrl = '';
  206. if (video.video && video.video.cover &&
  207. video.video.cover.url_list &&
  208. video.video.cover.url_list.length > 0) {
  209. coverUrl = video.video.cover.url_list[0];
  210. }
  211. // Extract dynamic cover URL (animated)
  212. let dynamicCoverUrl = '';
  213. if (video.video && video.video.dynamic_cover &&
  214. video.video.dynamic_cover.url_list &&
  215. video.video.dynamic_cover.url_list.length > 0) {
  216. dynamicCoverUrl = video.video.dynamic_cover.url_list[0];
  217. }
  218. return {
  219. id,
  220. desc,
  221. title,
  222. createTime,
  223. videoUrl,
  224. audioUrl,
  225. coverUrl,
  226. dynamicCoverUrl
  227. };
  228. }
  229.  
  230. static processVideoData(data) {
  231. // Check if we have valid data with the aweme_list property
  232. if (!data || !data.aweme_list || !Array.isArray(data.aweme_list)) {
  233. console.warn("Invalid video data format", data);
  234. return [];
  235. }
  236. // Process each video to extract metadata
  237. return data.aweme_list
  238. .map(video => this.extractVideoMetadata(video))
  239. .filter(video => video && video.videoUrl); // Filter out videos without URLs
  240. }
  241. }
  242.  
  243. class FileHandler {
  244. static saveVideoUrls(videoData, options = { downloadJson: true, downloadTxt: true }) {
  245. if (!videoData || videoData.length === 0) {
  246. console.warn("No video data to save");
  247. return { savedCount: 0 };
  248. }
  249. const now = new Date();
  250. const timestamp = now.toISOString().replace(/[:.]/g, '-');
  251. let savedCount = 0;
  252. // Save complete JSON data if option is enabled
  253. if (options.downloadJson) {
  254. const jsonContent = JSON.stringify(videoData, null, 2);
  255. const jsonBlob = new Blob([jsonContent], { type: 'application/json' });
  256. const jsonUrl = URL.createObjectURL(jsonBlob);
  257. const jsonLink = document.createElement('a');
  258. jsonLink.href = jsonUrl;
  259. jsonLink.download = `douyin-video-data-${timestamp}.json`;
  260. jsonLink.style.display = 'none';
  261. document.body.appendChild(jsonLink);
  262. jsonLink.click();
  263. document.body.removeChild(jsonLink);
  264. console.log(`Saved ${videoData.length} videos with metadata to JSON file`);
  265. }
  266. // Save plain URLs list if option is enabled
  267. if (options.downloadTxt) {
  268. // Create a list of video URLs
  269. const urlList = videoData.map(video => video.videoUrl).join('\n');
  270. const txtBlob = new Blob([urlList], { type: 'text/plain' });
  271. const txtUrl = URL.createObjectURL(txtBlob);
  272. const txtLink = document.createElement('a');
  273. txtLink.href = txtUrl;
  274. txtLink.download = `douyin-video-links-${timestamp}.txt`;
  275. txtLink.style.display = 'none';
  276. document.body.appendChild(txtLink);
  277. txtLink.click();
  278. document.body.removeChild(txtLink);
  279. console.log(`Saved ${videoData.length} video URLs to text file`);
  280. }
  281. savedCount = videoData.length;
  282. return { savedCount };
  283. }
  284. }
  285.  
  286. class DouyinDownloader {
  287. constructor(statusElement) {
  288. this.statusElement = statusElement;
  289. this.downloadOptions = { downloadJson: true, downloadTxt: true };
  290. }
  291.  
  292. validateEnvironment() {
  293. // Check if we're on a Douyin user profile page
  294. const url = window.location.href;
  295. return url.includes('douyin.com/user/');
  296. }
  297.  
  298. extractSecUserId() {
  299. const url = window.location.href;
  300. const match = url.match(/user\/([^?/]+)/);
  301. return match ? match[1] : null;
  302. }
  303. updateStatus(message) {
  304. if (this.statusElement) {
  305. this.statusElement.textContent = message;
  306. }
  307. console.log(message);
  308. }
  309.  
  310. async downloadAllVideos() {
  311. try {
  312. if (!this.validateEnvironment()) {
  313. this.updateStatus('This script only works on Douyin user profile pages');
  314. return;
  315. }
  316.  
  317. const secUserId = this.extractSecUserId();
  318. if (!secUserId) {
  319. this.updateStatus('Could not find user ID in URL');
  320. return;
  321. }
  322.  
  323. this.updateStatus('Starting download process...');
  324. const client = new DouyinApiClient(secUserId);
  325. let hasMore = true;
  326. let maxCursor = 0;
  327. let allVideos = [];
  328. while (hasMore) {
  329. this.updateStatus(`Fetching videos, cursor: ${maxCursor}...`);
  330. const data = await retryWithDelay(async () => {
  331. return await client.fetchVideos(maxCursor);
  332. });
  333. const videos = VideoDataProcessor.processVideoData(data);
  334. allVideos = allVideos.concat(videos);
  335. this.updateStatus(`Found ${videos.length} videos (total: ${allVideos.length})`);
  336. // Check if there are more videos to fetch
  337. hasMore = data.has_more === 1;
  338. maxCursor = data.max_cursor;
  339. // Add a delay to avoid rate limiting
  340. await sleep(CONFIG.REQUEST_DELAY_MS);
  341. }
  342. if (allVideos.length === 0) {
  343. this.updateStatus('No videos found for this user');
  344. return;
  345. }
  346. this.updateStatus(`Processing ${allVideos.length} videos...`);
  347. const result = FileHandler.saveVideoUrls(allVideos, this.downloadOptions);
  348. this.updateStatus(`Download complete! Saved ${result.savedCount} videos`);
  349. } catch (error) {
  350. console.error('Download failed:', error);
  351. this.updateStatus(`Error: ${error.message}`);
  352. }
  353. }
  354. }
  355.  
  356. async function run() {
  357. // Wait for the page to load fully
  358. setTimeout(() => {
  359. addUI();
  360. console.log('Douyin Video Downloader initialized');
  361. }, 2000);
  362. }
  363.  
  364. // Initialize the script
  365. run();
  366. })();