// ==UserScript==
// @name Lihuelworks's Tiktok - Get list of tiktok links (Updated!)
// @namespace Violentmonkey Scripts
// @version 0.2
// @license CC-BY-NC-SA-4.0
// @run-at document-end
// @icon https://www.tiktok.com/favicon.ico
// @homepageURL https://github.com/lihuelworks/tiktok-to-ytdlp-userscript
// @description Adds a button to TikTok to get a list of all tiktok video links (e.g from a tiktok profile) to use in yt-dlp (Scroll an list link download code is by Dinoosauro https://github.com/Dinoosauro/tiktok-to-ytdlp)
// @author Lihuelworks (with code from Dinoosauro's https://github.com/Dinoosauro/tiktok-to-ytdlp)
// @match https://www.tiktok.com/*
// @grant GM_addStyle
// ==/UserScript==
(function () {
"use strict";
// Add custom styles including reset and Material Design
GM_addStyle(`
#lihuelworks-tiktok-links-download-button {
border: none;
margin: 0;
padding: 0;
width: auto;
overflow: visible;
background: transparent;
color: inherit;
font: inherit;
line-height: normal;
-webkit-font-smoothing: inherit;
-moz-osx-font-smoothing: inherit;
-webkit-appearance: none;
}
#lihuelworks-tiktok-links-download-button::-moz-focus-inner {
border: 0;
padding: 0;
}
/* Material Design styles */
#lihuelworks-tiktok-links-download-button {
padding: 12px 24px;
background-color: #6200ea;
color: white;
font-size: 14px;
font-weight: 500;
border-radius: 4px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
cursor: pointer;
transition: background-color 0.3s ease, transform 0.3s ease;
}
#lihuelworks-tiktok-links-download-button:hover {
background-color: #3700b3;
transform: scale(1.05);
}
#lihuelworks-tiktok-links-download-button:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(98, 0, 234, 0.5);
}
#lihuelworks-tiktok-links-download-button:active {
background-color: #03dac6;
}
`);
// Function to create and append the download button
function addDownloadButton() {
// Create a button element
let downloadButton = document.createElement("button");
downloadButton.textContent = "Get list of TikTok links";
downloadButton.style.order = "-1";
downloadButton.style.zIndex = "9999999";
downloadButton.id = "lihuelworks-tiktok-links-download-button";
// Get the first element with a class that contains "DivSearchWrapper"
let searchWrapper = document.querySelectorAll(
'[class*="DivSearchWrapper"]'
)[0];
if (searchWrapper) {
// Insert the button above the first element with class containing DivSearchWrapper
searchWrapper.parentElement.insertBefore(downloadButton, searchWrapper);
console.log("Button added!");
} else {
console.log("DivSearchWrapper not found!");
}
// Add click event listener to the button
downloadButton.addEventListener("click", function () {
// Call the function to start downloading TikTok videos
tiktoktoytdlp();
});
}
// Function to start downloading TikTok videos
function tiktoktoytdlp() {
// Using var in the global part of the script so that the script can be re-used also in WebKit & Gecko
var scriptOptions = {
scrolling_min_time: 1300, // Change the mininum time the script will try to refresh the page
scrolling_max_time: 2100, // Change the maxinum time the script will try to refresh the page
min_views: -1, // If a video has fewer views than this, it won't be included in the script.
delete_from_next_txt: true, // Delete all the items put in the previous .txt file when asking for a new one. Useful only if you want to obtain a .txt file while scrolling.
output_name_type: 2, // Put a string to specify a specific name of the file. Put 0 for trying to fetching it using data tags, 1 for fetching it from the window title, 2 for fetching it from the first "h1" element. _Invalid_ inputs will use the standard "TikTokLinks.txt". This will be edited if a different value is passed from the startDownload() function.
adapt_text_output: true, // Replace characters that are prohibited on Windows
allow_images: true, // Save also TikTok Image URLs
export_format: "txt", // Put "json" to save everything as a JSON file.
exclude_from_json: [], // If you plan to export the content in a JSON file, here you can exclude some properties from the JSON output. You can exclude "url", "views", "caption".
advanced: {
get_array_after_scroll: false, // Gets the item links after the webpage is fully scrolled, and not after every scroll.
get_link_by_filter: true, // Get the website link by inspecting all the links in the container div, instead of looking for data references.
check_nullish_link: true, // Check if a link is nullish and, if true, try with the next video.
log_link_error: true, // Write in the console if there's an error when fetching the link.
maximum_downloads: Infinity, // Change this to a finite number to fetch only a specific number of values. Note that a) more elements might be added to the final file if available; and b) "get_array_after_scroll" must be set to false.
delete_from_dom: false, // Automatically delete the added items from the DOM. This works only if "get_array_after_scroll" is disabled. This is suggested only if you need to download a page with lots of videos
get_video_container_from_e2e: false, // Use the [data-e2e] attributes for getting the video container, instead of the normal CSS class.
},
node: {
resolve: null,
isNode: false,
isResolveTime: false,
},
};
/**
* A function that is able to read a double array, composed with `[["the property name", "the property value"]]`, and change the value of the scriptOptions array
* @param {string[][]} customTypes the double array
*/
function nodeElaborateCustomArgs(customTypes) {
if ((customTypes ?? "") !== "") {
// If the provided value isn't nullish
customTypes.forEach((e) => {
// Get each value
var optionChange = e[0].split("=>"); // The arrow (=>) is used to indicate that the property is in a nested object (ex: advanced=>log_link_error).
optionChange.length === 1
? (scriptOptions[e[0]] = e[1])
: (scriptOptions[optionChange[0]][optionChange[1]] = e[1]); // If the length is 1, just change the option. Otherwise, look for the nested object and change its value
});
}
}
// SCRIPT START:
var height = document.body.scrollHeight;
/**
* A Map that contains the video URL as its key, and the video views and caption as its value
*/
var containerMap = new Map([]);
/**
* The array of video links to skip
*/
var skipLinks = [];
/**
* Scroll the webpage
*/
function loadWebpage() {
if (
document.querySelectorAll(".tiktok-qmnyxf-SvgContainer").length === 0
) {
// Checks if the SVG loading animation is present in the DOM
!scriptOptions.advanced.get_array_after_scroll &&
scriptOptions.advanced.delete_from_dom &&
window.scrollTo({
top:
document.body.scrollHeight -
window.outerHeight * (window.devicePixelRatio || 1),
behavior: "smooth",
}); // If items from the DOM are removed, the page must be scrolled a little bit higher, so that the TikTok refresh is triggered
setTimeout(
() => {
window.scrollTo({
top: document.body.scrollHeight,
behavior: "smooth",
}); // Scroll to the bottom of the page
setTimeout(() => {
if (height !== document.body.scrollHeight) {
// The webpage has scrolled the previous time, so we can try another scroll
if (!scriptOptions.advanced.get_array_after_scroll) {
addArray();
if (
scriptOptions.advanced.maximum_downloads <
Array.from(containerMap).length + skipLinks.length
) {
// If the number of fetched items is above the permitted one, download the script and don't do anything.
ytDlpScript();
return;
}
}
setTimeout(() => {
height = document.body.scrollHeight;
loadWebpage();
}, Math.floor(Math.random() * scriptOptions.scrolling_max_time + scriptOptions.scrolling_min_time));
} else {
setTimeout(() => {
if (
document.querySelectorAll(".tiktok-qmnyxf-SvgContainer")
.length === 0 &&
height == document.body.scrollHeight
) {
// By scrolling, the webpage height doesn't change, so let's download the txt file
scriptOptions.node.isResolveTime = true;
ytDlpScript();
skipLinks = []; // Restore so that the items can be re-downloaded
} else {
// The SVG animation is still there, so there are other contents to load.
loadWebpage();
}
}, 3500);
}
}, 150);
},
!scriptOptions.advanced.get_array_after_scroll &&
scriptOptions.advanced.delete_from_dom
? Math.floor(Math.random() * 600 + 600)
: 1
);
} else {
// Let's wait 1 second, so that TikTok has time to load content.
setTimeout(function () {
loadWebpage();
}, 1000);
}
}
/**
* Elaborate items in the page
*/
function addArray() {
const e2eLinks =
"[data-e2e=user-liked-item], [data-e2e=music-item], [data-e2e=user-post-item], [data-e2e=favorites-item], [data-e2e=challenge-item], [data-e2e=search_top-item]";
let container = document.querySelectorAll(
scriptOptions.advanced.get_video_container_from_e2e
? e2eLinks
: ".tiktok-1uqux2o-DivItemContainerV2, .css-ps7kg7-DivThreeColumnItemContainer, .tiktok-x6y88p-DivItemContainerV2, .css-1uqux2o-DivItemContainerV2, .css-x6y88p-DivItemContainerV2, .css-1soki6-DivItemContainerForSearch, .css-ps7kg7-DivThreeColumnItemContainer"
); // Class of every video container
if (scriptOptions.advanced.get_video_container_from_e2e)
container = Array.from(container).map((item) => item.parentElement);
for (const tikTokItem of container) {
if (!tikTokItem) continue; // Skip nullish results
const getLink = scriptOptions.advanced.get_link_by_filter
? Array.from(tikTokItem.querySelectorAll("a")).filter(
(e) =>
e.href.indexOf("/video/") !== -1 ||
e.href.indexOf("/photo/") !== -1
)[0]?.href
: tikTokItem
.querySelector(`[data-e2e=user-post-item-desc], ${e2eLinks}`)
?.querySelector("a")?.href; // If the new filter method is selected, the script will look for the first link that contains a video link structure. Otherwise, the script'll look for data tags that contain the video URL.
if (!scriptOptions.allow_images && getLink.indexOf("/photo/") !== -1)
continue; // Avoid adding photo if the user doesn't want to.
if (
scriptOptions.advanced.check_nullish_link &&
(getLink ?? "") === ""
) {
// If the script needs to check if the link is nullish, and it's nullish...
if (scriptOptions.advanced.log_link_error)
console.log("SCRIPT ERROR: Failed to get link!"); // If the user wants to print the error in the console, write it
continue; // And, in general, continue with the next link.
}
if (skipLinks.indexOf(getLink) === -1) {
const views =
tikTokItem.querySelector(
".css-cralc2-SpanPlayCount, [data-e2e=video-views]"
)?.innerHTML ?? "0";
const caption =
tikTokItem.querySelector(".css-vi46v1-DivDesContainer a span")
?.textContent ??
tikTokItem.querySelector(".css-a3te33-AVideoContainer picture img")
?.alt ??
"";
containerMap.set(getLink, {
views: `${views
.replace(".", "")
.replace("K", "00")
.replace("M", "00000")}${
(views.indexOf("K") !== -1 || views.indexOf("M") !== -1) &&
views.indexOf(".") === -1
? "0"
: ""
}`,
caption,
});
}
}
if (
!scriptOptions.advanced.get_array_after_scroll &&
scriptOptions.advanced.delete_from_dom
) {
// Delete all the items from the DOM. Only the last 20 items will be kept.
for (const item of Array.from(container).slice(
0,
container.length - 20
))
item.remove();
}
}
/**
* Replace a name with allowed Windows characters.
* @param {string} name
* @returns the "sanitized" string
*/
function sanitizeName(name) {
return name
.replaceAll("<", "‹")
.replaceAll(">", "›")
.replaceAll(":", "∶")
.replaceAll('"', "″")
.replaceAll("/", "∕")
.replaceAll("\\", "∖")
.replaceAll("|", "¦")
.replaceAll("?", "¿")
.replaceAll("*", "");
}
/**
* Delete the keys that the user doesn't want in the output JSON
* @param {any} obj
* @returns the object without those keys
*/
function deleteUnrequestedContent(obj) {
for (const key in obj)
if (scriptOptions.exclude_from_json.indexOf(key) !== -1)
delete obj[key];
if (Object.keys(obj).length === 1) return obj[Object.keys(obj)[0]];
return obj;
}
/**
* Generate the output file
* @returns if running on Node, a string array or an Object. If running on the console, undefined.
*/
function ytDlpScript() {
addArray(); // Add the last elements in the DOM, or all the elements if get_array_after_scroll is set to true.
// Create the txt file with all of the TikTok links.
let ytDlpScript = scriptOptions.export_format === "json" ? [] : "";
for (const [url, obj] of Array.from(containerMap)) {
if (+obj.views < scriptOptions.min_views) continue;
scriptOptions.export_format === "json"
? ytDlpScript.push(deleteUnrequestedContent({ ...obj, url }))
: (ytDlpScript += `${url}\n`);
}
if (scriptOptions.node.isNode && !scriptOptions.node.isResolveTime)
return getWhatToReturn(ytDlpScript);
else
downloadScript(
typeof ytDlpScript === "object"
? JSON.stringify(ytDlpScript)
: ytDlpScript
); // If the user has requested from Node to get the array, get it
}
/**
* Get if a JSON object array should be returned, or if a splitted string.
* @param {any | string} content the content that needs to be returned
* @returns the content to return
*/
function getWhatToReturn(content) {
return typeof content === "object" ? content : content.split("\n");
}
/**
* Download the script text to a file
* @param {string} script the content of the output file
* @param {boolean} force force download of the script, even if on Node
*/
function downloadScript(script, force) {
if (scriptOptions.node.isNode && !force) {
if (scriptOptions.node.isResolveTime)
scriptOptions.node.resolve(getWhatToReturn(script));
else return getWhatToReturn(script);
scriptOptions.node.resolve = null;
scriptOptions.node.isResolveTime = false;
return;
}
const blob = new Blob([script], { type: "text/plain" }); // Create a blob with the text
const link = document.createElement("a");
let name = `TikTokLinks.${scriptOptions.export_format}`; // Set the standard name
switch (
scriptOptions.output_name_type // Look at the type of the name
) {
case 0: // Fetch name from data tags
name =
document
.querySelector("[data-e2e=user-title]")
?.textContent.trim() ??
document
.querySelector("[data-e2e=browse-username]")
?.firstChild?.textContent.trim() ??
document
.querySelector("[data-e2e=browse-username]")
?.textContent.trim() ??
document
.querySelector("[data-e2e=challenge-title]")
?.textContent.trim() ??
document
.querySelector("[data-e2e=music-title]")
?.textContent.trim() ??
`TikTokLinks.${scriptOptions.export_format}`;
break;
case 1: // Fetch name from the website title
name = `${document.title.substring(
0,
document.title.indexOf(" | TikTok")
)}.${scriptOptions.export_format}`;
break;
case 2: // Fetch name from the first "h1" element on the page
name = `${
document.querySelector("h1")?.textContent.trim() ?? "TikTokLinks"
}.${scriptOptions.export_format}`;
break;
}
if (typeof scriptOptions.output_name_type === "string")
name = scriptOptions.output_name_type; // If it's a string, apply it to the output name
if (scriptOptions.adapt_text_output) name = sanitizeName(name); // If the user wants to use safe characters only, adapt the string name.
link.href = URL.createObjectURL(
new File([blob], name, {
type:
scriptOptions.export_format === "json"
? "application/json"
: "text/plain",
})
);
link.download = name;
link.click();
URL.revokeObjectURL(link.href);
}
/**
* Write requestTxtNow() in the console to obtain the .txt file while converting. Useful if you have lots of items, and you want to start downloading them.
* @returns the current script
*/
function requestTxtNow() {
const value = ytDlpScript();
if (scriptOptions.delete_from_next_txt) {
// If delete_from_next_txt is enabled, delete the old items, so that only the newer ones will be downloaded.
skipLinks.push(...Array.from(containerMap).map((item) => item[0]));
containerMap = new Map([]);
}
return value;
}
function startDownload(name) {
containerMap = new Map([]);
skipLinks = [];
if ((name ?? "") !== "") scriptOptions.output_name_type = name; // Update the file name type if it's provided a non-nullish value
if (scriptOptions.node.isNode) {
return new Promise((resolve) => {
scriptOptions.node.resolve = resolve;
loadWebpage();
});
} else loadWebpage(); // And start scrolling the webpage
}
nodeElaborateCustomArgs();
startDownload(); // Add as an argument a custom file name (or a custom file type value), or edit it from the scriptOptions.output_name_type
}
// Run the addDownloadButton function to add the button to the page
window.addEventListener("load", function () {
addDownloadButton();
});
})();