Freetube/Youtube/GrayJay script to export notes to obsidian

This is my script for exporting links from freetube to obsidian using clipboard.

Usage

  • Open dev tools (F12) in freetube
  • Go to tab sources
  • Create new snippet, past code and run it (ctr+enter)
  • When compiled or when exportYoutubeMetadata(); is called it will print formatted link on console and copy it to system clipboard.

Link formatting

By default it will export link like this:

- Video title - [DD/MM/YYYY](https:youtu.be/xxxxxxxxxxx) - [[Channel]]

Customisation

Link Format

  • Change method ListFormater.format() or any other method in this class.

Change Channel Name

  • There is map NamesToConvert that stores channel names that should be converted to other name.
  • First string is original channel name
  • Second is the name that you want in your link.

Video titles

  • Some channels are putting they channel name to video tittle. TitlesToTrim is used to remove anything from video tittle for given channel.
  • First string is channel name.
    • If you change it NamesToConvert you should use second value from this map as key
  • Second value is array of strings that should be removed.
    • Make sure that you have copied exact string you want to remove, some whitespaces can cause troubles.
    • You can remove multiple strings, for single channel but they have to be in this format
    TitlesToTrim.set("channel1", ["remove"]);
    TitlesToTrim.set("channel2", ["remove1", "remove2"]);
    

Script

Contributions

  • I would want to add button to freetube UI that run this code without using dev tools, but I’m not js dev, and never worked with electron apps. If you know how to do this, I’m open for suggestions.

Know Issues

  • Exported publish date can be broken when freetube interface language is other then english
    • To fix this find "Published on " in script and change it to match your language.
1 Like
const NamesToConvert = new Map();
NamesToConvert.set('VisualPolitik EN', "VisualPolitik");

const TitlesToTrim = new Map();
TitlesToTrim.set("VisualPolitik", [" | VisualPolitik EN"," | @VisualPolitikEN"," | <U+202A>@VisualPolitikEN<U+202C>", " <U+202A>@VisualPolitikEN<U+202C>", " - VisualPolitik EN"]);

const DescPatterns = ['\.com',
                    '\.org',
                    '\.gov',
                    'patreon',
                    '---',
                    '====',
                    '\.mp',
                    'bsky\.app',
                    'email list',
                    `utm_source`,
                    `utm_medium`,
                    '\.xyz',
                    '\.io',
                    'nocopyrightsounds',
                    'referral links',
                    '\.gg',
                    'sponsor of',
                    '\.is',
                    'bit\.ly',
                    'join this channel',
                    'free shipping',
                    'ground\.news',
                     'amzn\.to',
                     'twitch\.tv',
                     'PayPal',
                     'please subscribe',
                     'youtube membership',
                     'affiliate links',
                     'help this channel',
                     'licenses used:',
                     'bc1qnkl3nk0zt7w0xzrgur9pnkcduj7a3xxllcn7d4',
                     '0x60f088B10b03115405d313f964BeA93eF0Bd3DbF',
                     "Copyright Disclaimer"]


Node.prototype.getElementsByPartialClassName = function(partialName) {
        return this.querySelectorAll(`[class*='${partialName}']`);
}

function getElementFromParent(resultPrefix, parentPrefix) {
    const parent = document.getElementsByPartialClassName( parentPrefix)
    return Array.from(parent)
        .flatMap(desc => Array.from(desc.getElementsByPartialClassName(resultPrefix)))
}

String.prototype.removewhitespaces = function() {
    // show(this.match("/\xA0/g"));
    // show(this.match("/\n/g"));
    // show(this.match("/\u202C/g"));

    return this.replace(/\n/g, '').replace(/\xA0/g, " ").// \xA0 is ASCI 160 - no breaking space
    replace(/\u202C/g, "").trim();
}

String.prototype.uncapitalize = function() {
    return this.split(' ').map(word => {
        if (word.isCapitalized()) {
            return word.slice(0, 1).toUpperCase() + word.slice(1).toLowerCase();
        } else {
            return word;
        }
    }
    ).join(' ');
}
;

String.prototype.isCapitalized = function() {
    var isCapitalized = false;
    for (var c of this) {
        if (c !== c.toUpperCase()) {
            return false;
        }
    }
    return true;
}

String.prototype.escape = function() {
    return this.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
}

var hiddenUI = false;
function hideUI() {
    const top = document.getElementsByClassName("playerFullscreenTitleOverlay")[0].style;
    const bottom = document.getElementsByClassName("shaka-bottom-controls shaka-no-propagation")[0].style;
    if (hiddenUI) {
        top.display = "";
        bottom.display = "";
    } else {
        top.display = "none";
        bottom.display = "none";
    }
    hiddenUI = !hiddenUI;
}

class Chapters {
    constructor(generate, timestamps) {
        if (this.constructor === Chapters) {
            throw new Error("Chapters is an abstract class and cannot be instantiated directly.");
        }
        this.generate = generate
        this.timestamps = timestamps
        this.data = [];
        return this;
    }

    formatTimestamp(time, url, pos) {
        const i = pos + 1;
        const ts = this.getTimestamp(time.split(":").reverse());
        return "[" + i + "](" + url + "?t=" + ts + ")"
    }

    getTimestamp(array) {
        if (!(array instanceof Array)) {
            throw new TypeError('Argument must be an instance of Array');
        }

        if (array.length > 2) {
            throw new Error('Days are unsupported');
        }

        let seconds = 0;
        for (let i = 0; i < array.length; i++) {
            const val = parseInt(array[i]);
            if (i == 0) {
                seconds += val;
            } else if (i == 1) {
                seconds += val * 60;
            } else {
                seconds += val * 60 * 60;
            }
        }
        return seconds;
    }
}

class YoutubeChapters extends Chapters {
    read(url) {
        if (!this.generate)
            return this.data

        const container = document.querySelector("#content").getElementsByClassName("style-scope ytd-macro-markers-list-renderer")
        if (container.length < 1)
            return this.data

        const chapters = container[0].getElementsByClassName("style-scope ytd-macro-markers-list-renderer")

        for (let i = 0; i < chapters.length; i++) {
            const data = chapters[i].children[1].textContent.trim().split("\n")
            const desc = data[0].trim()
            const time = data[2].trim()

            if (this.timestamps) {
                this.data[i] = desc + " " + this.formatTimestamp(time, url, i)
            } else {
                this.data[i] = desc
            }
        }
        return this.data
    }
}

class GrayJayChapters extends Chapters {
    read(url) {
        if (!this.generate)
            return this.data

        const descriptions = document.getElementsByPartialClassName("_progressBarChapter_")
        for (let i = 0; i < descriptions.length; i++) {
            const desc = descriptions[i].textContent
            this.data[i] = desc
        }

        if (!this.timestamps)
            return this.data

        const timestamps = getElementFromParent("timestamp-link", "_description_")
        for (let i = 0; i < timestamps.length; i++) {
            const time = timestamps[i].textContent.trim()
            this.data[i] += " " + this.formatTimestamp(time, url, i)
        }
        return this.data
    }
}


class FreetubeChapters extends Chapters {
    read(url) {
        if (!this.generate)
            return this.data

        const htmlCol = document.getElementsByClassName("chapterTimestamp");

        for (let i = 0; i < htmlCol.length; i++) {
            const desc = htmlCol[i].nextElementSibling.outerText.removewhitespaces()

            if (this.timestamps) {
                const url = this.formatTimestamp(htmlCol[i].outerText.removewhitespaces(), url, i)
                this.data[i] = desc + " " + url
            } else {
                this.data[i] = desc
            }
        }
        return this.data
    }
}

class Extractor {
    constructor(chapters) {
        if (this.constructor === Extractor) {
            throw new Error("Extractor is an abstract class and cannot be instantiated directly.");
        }
        this.channel = this.getChannel().removewhitespaces()
        this.title = this.getTitle()
        this.url = this.getURL()
        this.date  = this.getPublishDate()
        this.description = this.getDescription()
        this.chapters = chapters.read(this.url)
    }
    getURL() {
        // eg: file:///app/freetube/resources/app.asar/dist/index.html#/watch/HtgvZreimGI
        return "https:youtu.be/" + document.URL.split("/").pop();
    }
    getChannel() {
        throw new Error("Calling abstract method 'getChannel()'");
    }
    getTitle() {
        throw new Error("Calling abstract method 'getTitle()'");
    }
    getPublishDate() {
        throw new Error("Calling abstract method 'getPublishDate()'");
    }
    getDescription() {
        throw new Error("Calling abstract method 'getDescription()'");
    }
}

class GrayJayExtractor extends Extractor {
    getURL() {
        const ele = document.getElementsByPartialClassName("_containerCasting_")[0].children[0]
        const tmp = ele.getAttribute("src").split("/")
        return "https:youtu.be/" + tmp[tmp.length-2]
    }
    getChannel() {
        return getElementFromParent("_authorName_", "_authorDescription")[0].textContent
    }
    getTitle() {
        return document.getElementsByPartialClassName("_title_")[0].textContent
    }
    getPublishDate() {
        return new Date()
        //return "Publish date is unsupported in GrayJay"
    }
    getDescription() {
        return document.getElementsByPartialClassName("_description_")[0].textContent.split('\n');
    }
}

class FreetubeExtractor extends Extractor {
    getChannel() {
        return getElement("channelName");
    }
    getTitle() {
        return getElement("videoTitle");
    }
    getPublishDate() {
        const text = getElement("datePublishedAndViewCount").replace('Published on ', '');
        return text.slice(0, text.indexOf('•') - 1)
            .removewhitespaces();
    }
    getDescription() {
        return document.querySelector("#app > div.ft-flex-box.flexBox.routerView > div > div.infoArea.infoAreaSticky > div.ft-card.watchVideo.videoDescription > p")
        .textContent.split('\n');
    }
}

class YoutubeExtractor extends Extractor {
    getChannel() {
        return document.querySelector("#text > a").textContent;
    }
    getTitle() {
        return document.querySelector("#title > h1 > yt-formatted-string").textContent
    }
    getPublishDate() {
        return document.querySelector("#info-strings > yt-formatted-string").textContent
    }
    getDescription() {
        return document.querySelector("#attributed-snippet-text > span").textContent.split('\n');
    }
}

class ListFormater {
    constructor(names, titles, extractor, uncapitalizeTitles) {
        this.names = names //NamesToConvert
        this.titles = titles //TitlesToTrim
        this.channel = this.channel(extractor.channel)
        this.title = this.title(extractor.title, this.channel, uncapitalizeTitles) // ToDo: consider changing second argument to extractor.channel (will break TitlesToTrim for channels that also use NamesToConvert)
        this.url = extractor.url
        this.date  = this.publishDate(extractor.date)
        this.chapters = extractor.chapters
    }

    format() {
        let text = "- " + this.title + " - [" + this.date + "](" + this.url + ") - [[" + this.channel + "]]";
        for (let i = 0; i < this.chapters.length; i++) {
            text += "\n\t- " + this.chapters[i];
        }
        return text;
    }

    title(title, channel, uncapitalizeTitles) {
        const trimText = this.titles.get(channel);
        if (trimText !== undefined) {
            trimText.forEach( (text) => {
                text = new RegExp(text.escape(),'i');
                title = title.replace(text, '');
            });
        }
        if (uncapitalizeTitles) {
            return title.removewhitespaces().uncapitalize();
        } else {
            return title.removewhitespaces()
        }
    }

    channel(channel) {
        const newChannelName = this.names.get(channel)
        return defaultValue(newChannelName, channel)
    }

    publishDate(date) {
        return new Date(date).toLocaleDateString();
    }

    subtractDays(days) {
        let date = new Date();
        date.setDate(date.getDate() - days);
        this.date = date.toLocaleDateString();
    }
}

function formatDescription(lines){
        const lines1 = lines.filter(str =>
            str.trim() !== "" &&
                DescPatterns.every(pattern => !str.toLowerCase().includes(pattern.toLowerCase()))
        );
        // console.log("Unparsed: %s", lines)
        return lines1.join('\n')
}


function getElement(className) {
    return document.getElementsByClassName(className)[0].textContent;
}

function defaultValue(value, defaultValue) {
    return value === undefined ? defaultValue : value;
}

function show(str) {
    console.log(str)
}

function determinePlatform() {
    if (document.URL.includes("freetube")) {
        return "freetube"
    }
    if (document.title === "Grayjay") {
        return "grayjay"
    }
    if (document.URL.includes("youtube.com")) {
        return "youtube"
    }
    return "Couldn't determine platform"
}

function extractorFactory(platform, generateChapters, timestamps) {
    if (platform === "freetube") {
        chapters = new FreetubeChapters(generateChapters, timestamps)
        return new FreetubeExtractor(chapters)
    } else if (platform === "youtube") {
        chapters = new YoutubeChapters(generateChapters, timestamps)
        return new YoutubeExtractor(chapters)
    } else if (platform === "grayjay"){
        chapters = new GrayJayChapters(generateChapters, timestamps)
        return new GrayJayExtractor(chapters)
    }
    throw new Error("Unknown platform.Supported: freetube OR youtube OR grayjay ; got " + platform);
}

platform = determinePlatform()
generateChapters = true
timestamps = false
uncapitalizeTitles = true
dateDifference = 0

data = extractorFactory(platform, generateChapters, timestamps)
formater = new ListFormater(NamesToConvert, TitlesToTrim, data, uncapitalizeTitles)
if (dateDifference !== 0) {
    formater.subtractDays(dateDifference)
}

output = formater.format()
if (platform === "freetube") {
    const {clipboard} = require('electron');
    await clipboard.writeText(output);
}
show(output);
show(formatDescription(data.description))

I have added two functions to this script.

  1. Now there are two global variables generateChapters and generateChapters. First determines if video chapters will be exported, second will determine if exported chapters will have urls with timestamps. Default script behaviour is generate chapters without timestamps.
  2. It will automatically uncapitalize video titles. You can disable it by setting uncapitalizeTitles to false.
Examples

Without timestamps:

- Something Is Causing Stars To Escape Super Fast From the Nearby Dwarf Galaxy - [21/02/2025](https:youtu.be/HjGOTBDLZYk) - [[Anton Petrov]]
	- Hypervelocity stars from Large Magellanic Cloud
	- What are hypervelocity stars and how do they form?
	- Studies on hypervelocity stars in the Milky Way and the halo
	- New study makes a surprising discovery
	- What's forming them? Potential explanations
	- Implications and conclusions

With timestamps:

- Something Is Causing Stars To Escape Super Fast From the Nearby Dwarf Galaxy - [21/02/2025](https:youtu.be/HjGOTBDLZYk) - [[Anton Petrov]]
	- Hypervelocity stars from Large Magellanic Cloud [1](https:youtu.be/HjGOTBDLZYk?t=0)
	- What are hypervelocity stars and how do they form? [2](https:youtu.be/HjGOTBDLZYk?t=32)
	- Studies on hypervelocity stars in the Milky Way and the halo [3](https:youtu.be/HjGOTBDLZYk?t=305)
	- New study makes a surprising discovery [4](https:youtu.be/HjGOTBDLZYk?t=365)
	- What's forming them? Potential explanations [5](https:youtu.be/HjGOTBDLZYk?t=445)
	- Implications and conclusions [6](https:youtu.be/HjGOTBDLZYk?t=480)

I have made two changes

  • New Platforms
    • Youtube
      • Why not browser extension
      • Know issues
    • GrayJay
      • Know issues
  • Exporting Video Descriptions

New Platforms

  • Now the same script can be used on GrayJay desktop app and youtube webpage.
  • Global variable platform is used to determine if it should run freetube, grayjay or youtube version.
    • I have added function that should correctly determine what platform you are using, but this variable can be set manually.

Know issues:

  • I did big refactoring to support youtube and greyjay, so freetube version might be temporary broken. This will be fixed when freetube will start working again.
  • Both Youtube and GrayJay export metadata only to console
    • To get access to clipboard it would have to be browser extension.
    • I’m not sure why, but I can’t load electron clipboard in grayjay

Youtube Support

Don’t run code you don’t understand in your browser. If you can’t undersantd what this code is doing, please don’t use this script.

Why not browser extension?

I’m not a fan of installing extensions in a browser, so I will not bother to create one.
If you wanna to use my code in your extension feel free to do so.

Know issues

  • If you are using youtube version you have to manually expand video description before running this script when exporting video chapters.

GrayJay Support

Know issues

  • GrayJay doesn’t display reliable publish date so it will export current date instead. Using variable dateDifference you can subtract N days from current day.
  • For chapters timestamps generation it relies on timestamps in video description - I’m unsure if they are required there for the video to have chapters, so sometimes timestamps in chapters might not be generated correctly.
    • Chapters descriptions are fetched from video progress bar, so every video that has chapters visible on progress bar should have correct descriptions.

Exporting Video Descriptions

  • By default script will print video description to console

Configuration

DescPatterns - This array contains regular expressions used to determine if line should be removed from exported description.