Tiktok - Get list of tiktok links

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)

// ==UserScript==
// @name         Tiktok - Get list of tiktok links
// @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        none
// ==/UserScript==

(function () {
    'use strict';

    // 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';
        // Get the div with id "app-header"
        let appHeader = document.getElementById('app-header');

        // Append the button to the div "app-header"
        appHeader.appendChild(downloadButton);
        console.log("Button added!");

        // 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 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
            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.
            },
            node: {
                resolve: null,
                isNode: false,
                isResolveTime: false
            }
        }
        function nodeElaborateCustomArgs(customTypes) { // 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
            if ((customTypes ?? "") !== "") { // If the provided value isn't nullish
                customTypes.forEach(e => { // Get each value
                    let 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;
        var containerSets = [[], []]; // Array: [[Video link], [Video views]]
        var skipLinks = []; // Array: [Video link to skip]
        function loadWebpage() {
            if (document.querySelectorAll(".tiktok-qmnyxf-SvgContainer").length === 0) { // Checks if the SVG loading animation is present in the DOM
                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();
                        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();
                            } else { // The SVG animation is still there, so there are other contents to load.
                                loadWebpage();
                            }
                        }, 3500)
                    }
                }, 150);
            } else { // Let's wait 1 second, so that TikTok has time to load content.
                setTimeout(function () {
                    loadWebpage()
                }, 1000);
            }
        }
        function addArray() {
            var getClass = document.querySelectorAll(".tiktok-x6y88p-DivItemContainerV2, .css-x6y88p-DivItemContainerV2"); // Class of every video container
            for (var i = 0; i < getClass.length; i++) {
                // Simple information scraping: the link (getLink) is put in the first array, while the views (getViews) are put in the second one
                var getLink = scriptOptions.advanced.get_link_by_filter ? Array.from(getClass[i].querySelectorAll("a")).filter(e => e.href.indexOf("/video/") !== -1)[0]?.href : (getClass[i].querySelector("[data-e2e=user-post-item-desc]") ?? getClass[i].querySelector("[data-e2e=user-liked-item]") ?? getClass[i].querySelector("[data-e2e=music-item]") ?? getClass[i].querySelector("[data-e2e=user-post-item]") ?? getClass[i].querySelector("[data-e2e=favorites-item]") ?? getClass[i].querySelector("[data-e2e=challenge-item]")).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.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 (containerSets[0].indexOf(getLink) === -1 && skipLinks.indexOf(getLink) === -1) { // If the link hasn't been used, add it to the ContainerSets.
                    containerSets[0].push(getLink);
                    containerSets[1].push(((getClass[i].querySelector("[data-e2e=video-views]"))?.innerHTML ?? "0").replace("K", "00").replace("M", "00000"));
                }
            }
        }
        function sanitizeName(name) { // Replace a name with allowed Windows characters.
            return name.replaceAll("<", "‹").replaceAll(">", "›").replaceAll(":", "∶").replaceAll("\"", "″").replaceAll("/", "∕").replaceAll("\\", "∖").replaceAll("|", "¦").replaceAll("?", "¿").replaceAll("*", "");
        }
        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.
            var ytDlpScript = "";
            for (var x = 0; x < containerSets[0].length; x++) {
                if (parseInt(containerSets[1][x]) < scriptOptions.min_views) continue;
                ytDlpScript += `${containerSets[0][x]}\n`;
            }
            if (scriptOptions.node.isNode && !scriptOptions.node.isResolveTime) return ytDlpScript.split("\n"); else downloadScript(ytDlpScript); // If the user has requested from Node to get the array, get it
        }
        function downloadScript(script) { // Download the script text to a file
            if (scriptOptions.node.isNode) {
                if (scriptOptions.node.isResolveTime) scriptOptions.node.resolve(script.split("\n")); else return script.split("\n");
                return;
            }
            var blob = new Blob([script], { type: "text/plain" }); // Create a blob with the text
            var link = document.createElement("a");
            var name = "TikTokLinks.txt"; // 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.txt";
                    break;
                case 1: // Fetch name from the website title
                    name = `${document.title.substring(0, document.title.indexOf(" | TikTok"))}.txt`;
                    break;
                case 2: // Fetch name from the first "h1" element on the page
                    name = `${document.querySelector("h1")?.textContent.trim() ?? "TikTokLinks"}.txt`;
                    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: "text/plain" }));
            link.download = name;
            link.click();
            URL.revokeObjectURL(link.href);
        }
        function requestTxtNow() {
            // 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.
            let 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(...containerSets[0]);
                containerSets = [[], []];
            }
            return value;
        }
        function startDownload(name) {
            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

    }


    // console.log("starting")
    let element_to_observe = document; // Watch Everything...
    let observer = new MutationObserver(function (mutations) {
        mutations.forEach(function (mutation) {
            if (mutation.addedNodes.length == 1) {
                let top_node = mutation.addedNodes[0]; // The added node
                if (top_node instanceof Element && top_node.id === "app-header") { // Check if it's an Element
                    console.log(top_node.id)
                    let appHeader = top_node.querySelector("#app-header"); // Look for app-header element
                    addDownloadButton();
                }
            }
        });
    });

    observer.observe(element_to_observe, { childList: true, subtree: true });


})();