Greasy Fork 支持简体中文。

Douyin User Video Downloader

Extract video links and metadata from Douyin user profiles

  1. // ==UserScript==
  2. // @name Douyin User Video Downloader
  3. // @namespace https://github.com/CaoCuong2404
  4. // @version 1.6
  5. // @description Extract video links 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. // @run-at document-end
  11. // ==/UserScript==
  12.  
  13. (function () {
  14. "use strict";
  15.  
  16. // Add Tailwind CSS
  17. const tailwindCDN = document.createElement("script");
  18. tailwindCDN.src = "https://cdn.tailwindcss.com";
  19. document.head.appendChild(tailwindCDN);
  20.  
  21. // Global state
  22. const state = {
  23. videos: [],
  24. selectedVideos: new Set(),
  25. isFetching: false,
  26. fetchedCount: 0,
  27. totalFound: 0,
  28. isDialogOpen: false,
  29. };
  30.  
  31. function createMainUI() {
  32. // Create backdrop
  33. const backdrop = document.createElement("div");
  34. backdrop.className = "fixed inset-0 bg-black bg-opacity-50 z-[9999] hidden";
  35. backdrop.id = "douyin-downloader-backdrop";
  36.  
  37. // Create dialog container
  38. const container = document.createElement("div");
  39. container.className =
  40. "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";
  41. container.id = "douyin-downloader";
  42.  
  43. container.innerHTML = `
  44. <div class="flex flex-col max-h-[90vh]">
  45. <div class="flex items-center justify-between p-4 border-b">
  46. <div class="flex items-center space-x-2">
  47. <img src="https://www.douyin.com/favicon.ico" class="w-6 h-6" alt="Douyin">
  48. <h2 class="text-xl font-bold text-gray-800">Douyin Downloader</h2>
  49. </div>
  50. <button id="close-dialog" class="text-gray-400 hover:text-gray-600">
  51. <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  52. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
  53. </svg>
  54. </button>
  55. </div>
  56.  
  57. <div class="p-4 flex-1 overflow-hidden flex flex-col min-h-[500px]">
  58. <div id="fetch-status" class="text-sm text-gray-500 mb-4"></div>
  59.  
  60. <div class="border rounded-lg flex-1 flex flex-col overflow-hidden">
  61. <div class="p-4 border-b bg-gray-50 flex items-center justify-between">
  62. <div class="flex items-center space-x-4">
  63. <div class="flex items-center space-x-2">
  64. <input type="checkbox" id="select-all" class="rounded text-[#FE2C55]">
  65. <label for="select-all" class="text-sm font-medium text-gray-700">
  66. Select All (<span id="selected-count">0</span>/<span id="total-count">0</span>)
  67. </label>
  68. </div>
  69. <div class="h-4 border-l border-gray-300"></div>
  70. <div class="flex items-center space-x-2" id="action-buttons">
  71. <div class="relative inline-block text-left" id="download-dropdown">
  72. <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">
  73. Download
  74. <svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  75. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
  76. </svg>
  77. </button>
  78. <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">
  79. <div class="py-1">
  80. <button class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" data-action="audio">
  81. Download Audios (MP3)
  82. </button>
  83. <button class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" data-action="video">
  84. Download Videos (MP4)
  85. </button>
  86. <button class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" data-action="json">
  87. Download Metadata (JSON)
  88. </button>
  89. <button class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" data-action="txt">
  90. Download Links (TXT)
  91. </button>
  92. </div>
  93. </div>
  94. </div>
  95. </div>
  96. </div>
  97. <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">
  98. <span>Fetch Videos</span>
  99. </button>
  100. </div>
  101. <div class="overflow-auto flex-1">
  102. <table class="min-w-full divide-y divide-gray-200">
  103. <thead class="bg-gray-50 sticky top-0">
  104. <tr>
  105. <th scope="col" class="w-12 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
  106. Select
  107. </th>
  108. <th scope="col" class="w-16 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
  109. No.
  110. </th>
  111. <th scope="col" class="w-16 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
  112. Cover
  113. </th>
  114. <th scope="col" class="w-[300px] px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
  115. Title
  116. </th>
  117. <th scope="col" class="w-32 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
  118. Date
  119. </th>
  120. <th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
  121. Actions
  122. </th>
  123. </tr>
  124. </thead>
  125. <tbody id="videos-table-body" class="bg-white divide-y divide-gray-200">
  126. <!-- Videos will be inserted here -->
  127. </tbody>
  128. </table>
  129. </div>
  130. </div>
  131. </div>
  132. </div>
  133. `;
  134.  
  135. document.body.appendChild(backdrop);
  136. document.body.appendChild(container);
  137.  
  138. return { backdrop, container };
  139. }
  140.  
  141. async function addDownloadButton() {
  142. try {
  143. // Wait initial 2s for UI to stabilize and translations to complete
  144. await sleep(2000);
  145.  
  146. // Try to find the element multiple times
  147. let attempts = 3;
  148. let tabCountElement = null;
  149.  
  150. while (attempts > 0 && !tabCountElement) {
  151. try {
  152. tabCountElement = await waitForElement('[data-e2e="user-tab-count"]', 10000); // 10s timeout per attempt
  153. break;
  154. } catch (err) {
  155. attempts--;
  156. if (attempts > 0) {
  157. console.log("Retrying to find tab count element...");
  158. // Wait between attempts
  159. await sleep(1000);
  160. } else {
  161. throw new Error(
  162. "Could not find video count element after multiple attempts. This could be due to UI changes or page translation.",
  163. );
  164. }
  165. }
  166. }
  167.  
  168. // Extra check for parent element stability
  169. const parentElement = tabCountElement.parentNode;
  170. if (!parentElement || !parentElement.isConnected) {
  171. throw new Error("Parent element of video count is not stable");
  172. }
  173.  
  174. const downloadButton = document.createElement("button");
  175. downloadButton.className = "ml-2 text-[#FE2C55] hover:text-[#fe2c55]/90 transition-colors";
  176. downloadButton.innerHTML = `
  177. <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor">
  178. <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" />
  179. </svg>
  180. `;
  181. downloadButton.title = "Download all videos";
  182.  
  183. // Insert after the count with stability check
  184. if (tabCountElement.nextSibling) {
  185. parentElement.insertBefore(downloadButton, tabCountElement.nextSibling);
  186. } else {
  187. parentElement.appendChild(downloadButton);
  188. }
  189.  
  190. // Add click handler
  191. downloadButton.addEventListener("click", showDialog);
  192.  
  193. // Monitor for potential DOM changes that could affect the button
  194. const observer = new MutationObserver((mutations) => {
  195. if (!downloadButton.isConnected) {
  196. // Button was removed, try to re-add it
  197. if (tabCountElement.isConnected) {
  198. if (tabCountElement.nextSibling) {
  199. parentElement.insertBefore(downloadButton, tabCountElement.nextSibling);
  200. } else {
  201. parentElement.appendChild(downloadButton);
  202. }
  203. }
  204. }
  205. });
  206.  
  207. observer.observe(parentElement, {
  208. childList: true,
  209. subtree: true,
  210. });
  211. } catch (error) {
  212. console.error("Failed to add download button:", error);
  213. }
  214. }
  215.  
  216. function showDialog() {
  217. const backdrop = document.getElementById("douyin-downloader-backdrop");
  218. const dialog = document.getElementById("douyin-downloader");
  219.  
  220. backdrop.classList.remove("hidden");
  221. dialog.classList.remove("hidden");
  222.  
  223. // Add animation classes
  224. dialog.classList.add("animate-fade-in");
  225. backdrop.classList.add("animate-fade-in");
  226.  
  227. state.isDialogOpen = true;
  228. }
  229.  
  230. function hideDialog() {
  231. const backdrop = document.getElementById("douyin-downloader-backdrop");
  232. const dialog = document.getElementById("douyin-downloader");
  233.  
  234. backdrop.classList.add("hidden");
  235. dialog.classList.add("hidden");
  236.  
  237. state.isDialogOpen = false;
  238. }
  239.  
  240. function setupDialogEventListeners() {
  241. // Close button
  242. document.getElementById("close-dialog")?.addEventListener("click", hideDialog);
  243.  
  244. // Close on backdrop click
  245. document.getElementById("douyin-downloader-backdrop")?.addEventListener("click", hideDialog);
  246.  
  247. // Prevent dialog close when clicking inside
  248. document.getElementById("douyin-downloader")?.addEventListener("click", (e) => {
  249. e.stopPropagation();
  250. });
  251.  
  252. // Close on Escape key
  253. document.addEventListener("keydown", (e) => {
  254. if (e.key === "Escape" && state.isDialogOpen) {
  255. hideDialog();
  256. }
  257. });
  258. }
  259.  
  260. function createVideoRow(video, index) {
  261. const row = document.createElement("tr");
  262. row.className = "hover:bg-gray-50";
  263.  
  264. const date = new Date(video.createTime);
  265. const formattedDate = date.toLocaleDateString(undefined, {
  266. year: "numeric",
  267. month: "short",
  268. day: "numeric",
  269. });
  270.  
  271. row.innerHTML = `
  272. <td class="px-4 py-4 whitespace-nowrap">
  273. <input type="checkbox" data-video-id="${video.id}" class="video-checkbox rounded text-[#FE2C55]">
  274. </td>
  275. <td class="px-4 py-4 whitespace-nowrap text-sm text-gray-500">
  276. ${index + 1}
  277. </td>
  278. <td class="px-4 py-4 whitespace-nowrap">
  279. <div class="w-12 h-12 rounded-lg overflow-hidden">
  280. <img src="${video.dynamicCoverUrl || video.coverUrl}" class="w-full h-full object-cover" alt="${video.title}">
  281. </div>
  282. </td>
  283. <td class="px-4 py-4 whitespace-nowrap">
  284. <div class="text-sm text-gray-900 font-medium truncate max-w-[300px]" title="${video.title}">
  285. ${video.title}
  286. </div>
  287. </td>
  288. <td class="px-4 py-4 whitespace-nowrap text-sm text-gray-500">
  289. ${formattedDate}
  290. </td>
  291. <td class="px-4 py-4 whitespace-nowrap text-sm">
  292. <div class="flex items-center space-x-2">
  293. <a href="${video.videoUrl}" target="_blank" class="text-[#FE2C55] hover:text-[#fe2c55]/90">
  294. Video
  295. </a>
  296. ${
  297. video.audioUrl
  298. ? `
  299. <span class="text-gray-300">|</span>
  300. <a href="${video.audioUrl}" target="_blank" class="text-[#FE2C55] hover:text-[#fe2c55]/90">
  301. Audio
  302. </a>
  303. `
  304. : ""
  305. }
  306. </div>
  307. </td>
  308. `;
  309.  
  310. return row;
  311. }
  312.  
  313. function updateUI() {
  314. const selectedCount = state.selectedVideos.size;
  315. const totalCount = state.videos.length;
  316.  
  317. // Update counts
  318. document.getElementById("selected-count").textContent = selectedCount;
  319. document.getElementById("total-count").textContent = totalCount;
  320.  
  321. // Update select all checkbox
  322. const selectAllCheckbox = document.getElementById("select-all");
  323. selectAllCheckbox.checked = selectedCount === totalCount && totalCount > 0;
  324.  
  325. // Update download button
  326. const downloadBtn = document.getElementById("download-btn");
  327. downloadBtn.disabled = selectedCount === 0;
  328. }
  329.  
  330. function setupEventListeners() {
  331. // Fetch videos button
  332. document.getElementById("fetch-videos").addEventListener("click", async () => {
  333. if (state.isFetching) return;
  334.  
  335. state.isFetching = true;
  336. state.fetchedCount = 0;
  337. state.videos = [];
  338. state.selectedVideos.clear();
  339.  
  340. const button = document.getElementById("fetch-videos");
  341. const statusEl = document.getElementById("fetch-status");
  342. const tableBody = document.getElementById("videos-table-body");
  343. tableBody.innerHTML = "";
  344.  
  345. button.disabled = true;
  346. button.innerHTML = `
  347. <svg class="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24">
  348. <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
  349. <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>
  350. </svg>
  351. Fetching...
  352. `;
  353.  
  354. try {
  355. const downloader = new DouyinDownloader();
  356. await downloader.fetchAllVideos((newVideos) => {
  357. // Sort new videos by date (latest first)
  358. newVideos.sort((a, b) => new Date(b.createTime) - new Date(a.createTime));
  359.  
  360. // Add new videos to state
  361. state.videos.push(...newVideos);
  362. state.fetchedCount += newVideos.length;
  363.  
  364. // Update table
  365. state.videos.forEach((video, index) => {
  366. const existingRow = document.querySelector(`[data-video-id="${video.id}"]`)?.closest("tr");
  367. if (!existingRow) {
  368. tableBody.appendChild(createVideoRow(video, index));
  369. }
  370. });
  371.  
  372. // Update status
  373. statusEl.textContent = `Fetched ${state.fetchedCount} videos`;
  374. updateUI();
  375. });
  376.  
  377. setupTableEventListeners();
  378. } catch (error) {
  379. console.error("Error fetching videos:", error);
  380. statusEl.textContent = "Error: " + error.message;
  381. } finally {
  382. state.isFetching = false;
  383. button.disabled = false;
  384. button.innerHTML = "<span>Fetch Videos</span>";
  385. }
  386. });
  387.  
  388. // Download dropdown
  389. const downloadBtn = document.getElementById("download-btn");
  390. const dropdownMenu = document.getElementById("dropdown-menu");
  391.  
  392. downloadBtn.addEventListener("click", () => {
  393. dropdownMenu.classList.toggle("hidden");
  394. });
  395.  
  396. // Close dropdown when clicking outside
  397. document.addEventListener("click", (e) => {
  398. if (!downloadBtn.contains(e.target)) {
  399. dropdownMenu.classList.add("hidden");
  400. }
  401. });
  402.  
  403. // Download actions
  404. dropdownMenu.addEventListener("click", async (e) => {
  405. const action = e.target.dataset.action;
  406. if (!action) return;
  407.  
  408. const selectedVideos = state.videos.filter((v) => state.selectedVideos.has(v.id));
  409. if (selectedVideos.length === 0) return;
  410.  
  411. // Hide dropdown
  412. dropdownMenu.classList.add("hidden");
  413.  
  414. switch (action) {
  415. case "audio":
  416. await downloadFiles(selectedVideos, "audio");
  417. break;
  418. case "video":
  419. await downloadFiles(selectedVideos, "video");
  420. break;
  421. case "json":
  422. FileHandler.saveVideoUrls(selectedVideos, { downloadJson: true, downloadTxt: false });
  423. break;
  424. case "txt":
  425. FileHandler.saveVideoUrls(selectedVideos, { downloadJson: false, downloadTxt: true });
  426. break;
  427. }
  428. });
  429. }
  430.  
  431. function setupTableEventListeners() {
  432. // Select all checkbox
  433. document.getElementById("select-all").addEventListener("change", (e) => {
  434. const checkboxes = document.querySelectorAll(".video-checkbox");
  435. checkboxes.forEach((checkbox) => {
  436. checkbox.checked = e.target.checked;
  437. const videoId = checkbox.dataset.videoId;
  438. if (e.target.checked) {
  439. state.selectedVideos.add(videoId);
  440. } else {
  441. state.selectedVideos.delete(videoId);
  442. }
  443. });
  444. updateUI();
  445. });
  446.  
  447. // Individual video checkboxes
  448. document.querySelectorAll(".video-checkbox").forEach((checkbox) => {
  449. checkbox.addEventListener("change", (e) => {
  450. const videoId = e.target.dataset.videoId;
  451. if (e.target.checked) {
  452. state.selectedVideos.add(videoId);
  453. } else {
  454. state.selectedVideos.delete(videoId);
  455. }
  456. updateUI();
  457. });
  458. });
  459. }
  460.  
  461. // Configuration
  462. const CONFIG = {
  463. API_BASE_URL: "https://www.douyin.com/aweme/v1/web/aweme/post/",
  464. DEFAULT_HEADERS: {
  465. accept: "application/json, text/plain, */*",
  466. "accept-language": "vi",
  467. "sec-ch-ua": '"Not?A_Brand";v="8", "Chromium";v="118", "Microsoft Edge";v="118"',
  468. "sec-ch-ua-mobile": "?0",
  469. "sec-ch-ua-platform": '"Windows"',
  470. "sec-fetch-dest": "empty",
  471. "sec-fetch-mode": "cors",
  472. "sec-fetch-site": "same-origin",
  473. "user-agent":
  474. "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",
  475. },
  476. RETRY_DELAY_MS: 2000,
  477. MAX_RETRIES: 5,
  478. REQUEST_DELAY_MS: 1000,
  479. };
  480.  
  481. // Utility functions
  482. const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
  483.  
  484. const waitForElement = (selector, timeout = 30000, interval = 100) => {
  485. return new Promise((resolve, reject) => {
  486. // Check if element already exists
  487. const element = document.querySelector(selector);
  488. if (element) {
  489. resolve(element);
  490. return;
  491. }
  492.  
  493. // Set up the timeout
  494. const timeoutId = setTimeout(() => {
  495. observer.disconnect();
  496. clearInterval(checkInterval);
  497. reject(new Error(`Timeout waiting for element: ${selector}`));
  498. }, timeout);
  499.  
  500. // Set up the mutation observer
  501. const observer = new MutationObserver((mutations, obs) => {
  502. const element = document.querySelector(selector);
  503. if (element) {
  504. obs.disconnect();
  505. clearInterval(checkInterval);
  506. clearTimeout(timeoutId);
  507. resolve(element);
  508. }
  509. });
  510.  
  511. // Start observing
  512. observer.observe(document.body, {
  513. childList: true,
  514. subtree: true,
  515. });
  516.  
  517. // Also poll periodically as a backup
  518. const checkInterval = setInterval(() => {
  519. const element = document.querySelector(selector);
  520. if (element) {
  521. observer.disconnect();
  522. clearInterval(checkInterval);
  523. clearTimeout(timeoutId);
  524. resolve(element);
  525. }
  526. }, interval);
  527. });
  528. };
  529.  
  530. const retryWithDelay = async (fn, retries = CONFIG.MAX_RETRIES) => {
  531. let lastError;
  532. for (let i = 0; i < retries; i++) {
  533. try {
  534. return await fn();
  535. } catch (error) {
  536. lastError = error;
  537. console.log(`Attempt ${i + 1} failed:`, error);
  538. await sleep(CONFIG.RETRY_DELAY_MS);
  539. }
  540. }
  541. throw lastError;
  542. };
  543.  
  544. // API Client
  545. class DouyinApiClient {
  546. constructor(secUserId) {
  547. this.secUserId = secUserId;
  548. }
  549.  
  550. async fetchVideos(maxCursor) {
  551. const url = new URL(CONFIG.API_BASE_URL);
  552. const params = {
  553. device_platform: "webapp",
  554. aid: "6383",
  555. channel: "channel_pc_web",
  556. sec_user_id: this.secUserId,
  557. max_cursor: maxCursor,
  558. count: "20",
  559. version_code: "170400",
  560. version_name: "17.4.0",
  561. };
  562.  
  563. Object.entries(params).forEach(([key, value]) => url.searchParams.append(key, value));
  564.  
  565. const response = await fetch(url, {
  566. headers: {
  567. ...CONFIG.DEFAULT_HEADERS,
  568. referrer: `https://www.douyin.com/user/${this.secUserId}`,
  569. },
  570. credentials: "include",
  571. });
  572.  
  573. if (!response.ok) {
  574. throw new Error(`HTTP Error: ${response.status}`);
  575. }
  576.  
  577. return response.json();
  578. }
  579. }
  580.  
  581. // Data Processing
  582. class VideoDataProcessor {
  583. static extractVideoMetadata(video) {
  584. if (!video) return null;
  585.  
  586. // Initialize the metadata object
  587. const metadata = {
  588. id: video.aweme_id || "",
  589. desc: video.desc || "",
  590. title: video.desc || "", // Using desc as the title since title field isn't directly available
  591. createTime: video.create_time ? new Date(video.create_time * 1000).toISOString() : "",
  592. videoUrl: "",
  593. audioUrl: "",
  594. coverUrl: "",
  595. dynamicCoverUrl: "",
  596. };
  597.  
  598. // Extract video URL
  599. if (video.video?.play_addr) {
  600. metadata.videoUrl = video.video.play_addr.url_list[0];
  601. if (metadata.videoUrl && !metadata.videoUrl.startsWith("https")) {
  602. metadata.videoUrl = metadata.videoUrl.replace("http", "https");
  603. }
  604. } else if (video.video?.download_addr) {
  605. metadata.videoUrl = video.video.download_addr.url_list[0];
  606. if (metadata.videoUrl && !metadata.videoUrl.startsWith("https")) {
  607. metadata.videoUrl = metadata.videoUrl.replace("http", "https");
  608. }
  609. }
  610.  
  611. // Extract audio URL
  612. if (video.music?.play_url) {
  613. metadata.audioUrl = video.music.play_url.url_list[0];
  614. }
  615.  
  616. // Extract cover URL (static thumbnail)
  617. if (video.video?.cover) {
  618. metadata.coverUrl = video.video.cover.url_list[0];
  619. } else if (video.cover) {
  620. metadata.coverUrl = video.cover.url_list[0];
  621. }
  622.  
  623. // Extract dynamic cover URL (animated thumbnail)
  624. if (video.video?.dynamic_cover) {
  625. metadata.dynamicCoverUrl = video.video.dynamic_cover.url_list[0];
  626. } else if (video.dynamic_cover) {
  627. metadata.dynamicCoverUrl = video.dynamic_cover.url_list[0];
  628. }
  629.  
  630. return metadata;
  631. }
  632.  
  633. static processVideoData(data) {
  634. if (!data?.aweme_list) {
  635. return { videoData: [], hasMore: false, maxCursor: 0 };
  636. }
  637.  
  638. const videoData = data.aweme_list.map((video) => this.extractVideoMetadata(video)).filter((item) => item && item.videoUrl);
  639.  
  640. return {
  641. videoData,
  642. hasMore: data.has_more,
  643. maxCursor: data.max_cursor,
  644. };
  645. }
  646. }
  647.  
  648. // File Handler
  649. class FileHandler {
  650. static saveVideoUrls(videoData, options = { downloadJson: true, downloadTxt: true }) {
  651. if (!videoData || videoData.length === 0) {
  652. console.warn("No video data to save");
  653. return { savedCount: 0 };
  654. }
  655.  
  656. const now = new Date();
  657. const timestamp = now.toISOString().replace(/[:.]/g, "-");
  658. let savedCount = 0;
  659.  
  660. // Save complete JSON data if option is enabled
  661. if (options.downloadJson) {
  662. const jsonContent = JSON.stringify(videoData, null, 2);
  663. const jsonBlob = new Blob([jsonContent], { type: "application/json" });
  664. const jsonUrl = URL.createObjectURL(jsonBlob);
  665.  
  666. const jsonLink = document.createElement("a");
  667. jsonLink.href = jsonUrl;
  668. jsonLink.download = `douyin-video-data-${timestamp}.json`;
  669. jsonLink.style.display = "none";
  670. document.body.appendChild(jsonLink);
  671. jsonLink.click();
  672. document.body.removeChild(jsonLink);
  673.  
  674. console.log(`Saved ${videoData.length} videos with metadata to JSON file`);
  675. }
  676.  
  677. // Save plain URLs list if option is enabled
  678. if (options.downloadTxt) {
  679. // Create a list of video URLs
  680. const urlList = videoData.map((video) => video.videoUrl).join("\n");
  681. const txtBlob = new Blob([urlList], { type: "text/plain" });
  682. const txtUrl = URL.createObjectURL(txtBlob);
  683.  
  684. const txtLink = document.createElement("a");
  685. txtLink.href = txtUrl;
  686. txtLink.download = `douyin-video-links-${timestamp}.txt`;
  687. txtLink.style.display = "none";
  688. document.body.appendChild(txtLink);
  689. txtLink.click();
  690. document.body.removeChild(txtLink);
  691.  
  692. console.log(`Saved ${videoData.length} video URLs to text file`);
  693. }
  694.  
  695. savedCount = videoData.length;
  696. return { savedCount };
  697. }
  698. }
  699.  
  700. // Main Downloader
  701. class DouyinDownloader {
  702. constructor() {
  703. this.validateEnvironment();
  704. const secUserId = this.extractSecUserId();
  705. this.apiClient = new DouyinApiClient(secUserId);
  706. }
  707.  
  708. validateEnvironment() {
  709. if (typeof window === "undefined" || !window.location) {
  710. throw new Error("Script must be run in a browser environment");
  711. }
  712. }
  713.  
  714. extractSecUserId() {
  715. const secUserId = location.pathname.replace("/user/", "");
  716. if (!secUserId || location.pathname.indexOf("/user/") === -1) {
  717. throw new Error("Please run this script on a DouYin user profile page!");
  718. }
  719. return secUserId;
  720. }
  721.  
  722. async fetchAllVideos(onProgress) {
  723. let hasMore = true;
  724. let maxCursor = 0;
  725.  
  726. while (hasMore) {
  727. const data = await retryWithDelay(() => this.apiClient.fetchVideos(maxCursor));
  728. const { videoData, hasMore: more, maxCursor: newCursor } = VideoDataProcessor.processVideoData(data);
  729.  
  730. if (onProgress) {
  731. onProgress(videoData);
  732. }
  733.  
  734. hasMore = more;
  735. maxCursor = newCursor;
  736. await sleep(CONFIG.REQUEST_DELAY_MS);
  737. }
  738. }
  739. }
  740.  
  741. // Initialize the UI
  742. async function initializeUI() {
  743. // Add custom styles for animations
  744. const style = document.createElement("style");
  745. style.textContent = `
  746. @keyframes fadeIn {
  747. from { opacity: 0; }
  748. to { opacity: 1; }
  749. }
  750. .animate-fade-in {
  751. animation: fadeIn 0.2s ease-out;
  752. }
  753. `;
  754. document.head.appendChild(style);
  755.  
  756. // Create UI elements (hidden initially)
  757. createMainUI();
  758.  
  759. // Add download button to profile
  760. await addDownloadButton();
  761.  
  762. // Setup all event listeners
  763. setupEventListeners();
  764. setupTableEventListeners();
  765. setupDialogEventListeners();
  766. }
  767.  
  768. // Start the script
  769. if (document.readyState === "loading") {
  770. document.addEventListener("DOMContentLoaded", () => {
  771. initializeUI().catch((error) => {
  772. console.error("Failed to initialize UI:", error);
  773. });
  774. });
  775. } else {
  776. initializeUI().catch((error) => {
  777. console.error("Failed to initialize UI:", error);
  778. });
  779. }
  780.  
  781. async function downloadFile(url, filename) {
  782. try {
  783. const response = await fetch(url);
  784. if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
  785.  
  786. const blob = await response.blob();
  787. const blobUrl = URL.createObjectURL(blob);
  788.  
  789. const link = document.createElement("a");
  790. link.href = blobUrl;
  791. link.download = filename;
  792. link.style.display = "none";
  793. document.body.appendChild(link);
  794. link.click();
  795. document.body.removeChild(link);
  796.  
  797. // Clean up
  798. setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
  799.  
  800. return true;
  801. } catch (error) {
  802. console.error(`Failed to download ${filename}:`, error);
  803. return false;
  804. }
  805. }
  806.  
  807. async function downloadFiles(files, type = "video") {
  808. const statusEl = document.getElementById("fetch-status");
  809. const total = files.length;
  810. let successful = 0;
  811. let failed = 0;
  812.  
  813. for (let i = 0; i < files.length; i++) {
  814. const file = files[i];
  815. const url = type === "video" ? file.videoUrl : file.audioUrl;
  816. if (!url) {
  817. failed++;
  818. continue;
  819. }
  820.  
  821. // Update status
  822. statusEl.textContent = `Downloading ${type} ${i + 1}/${total}...`;
  823.  
  824. // Generate filename
  825. const timestamp = new Date(file.createTime).toISOString().split("T")[0];
  826. const filename = `douyin_${type}_${timestamp}_${file.id}.${type === "video" ? "mp4" : "mp3"}`;
  827.  
  828. // Download file
  829. const success = await downloadFile(url, filename);
  830. if (success) {
  831. successful++;
  832. } else {
  833. failed++;
  834. }
  835.  
  836. // Small delay between downloads to prevent browser blocking
  837. await sleep(500);
  838. }
  839.  
  840. // Final status update
  841. statusEl.textContent = `Download complete: ${successful} successful, ${failed} failed`;
  842. setTimeout(() => {
  843. statusEl.textContent = "";
  844. }, 5000);
  845. }
  846. })();