Bilibili Music Extractor

从B站上提取带封面的音乐

// ==UserScript==
// @name         Bilibili Music Extractor
// @namespace    http://tampermonkey.net/
// @version      0.4.1
// @description  从B站上提取带封面的音乐
// @author       ☆
// @include      https://www.bilibili.com/video/*
// @include      https://www.bilibili.com/festival/*
// @icon         https://www.bilibili.com/favicon.ico
// @require      https://unpkg.com/@ffmpeg/[email protected]/dist/ffmpeg.min.js
// @license      MIT
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // Your code here...

    const sanitizeStringAsFilename = (name) => {
        const allowedLength = 64;
        const replacement = '_';

        const reRelativePath = /^\.+(\\|\/)|^\.+$/;
        const reTrailingPeriods = /\.+$/;
        const reControlChars = /[\u0000-\u001F\u0080-\u009F]/g;
        const reRepeatedReservedCharacters = /([<>:"/\\|?*\u0000-\u001F]){2,}/g;
        const filenameReservedRegex = /[<>:"/\\|?*\u0000-\u001F]/g;
        const windowsReservedNameRegex= /^(con|prn|aux|nul|com\d|lpt\d)$/i;

        name = name.replace(reRepeatedReservedCharacters, '$1')
        name = name.normalize('NFD');
        name = name.replace(reRelativePath, replacement);
        name = name.replace(filenameReservedRegex, replacement);
        name = name.replace(reControlChars, replacement);
        name = name.replace(reTrailingPeriods, '');

		if (name[0] === '.') {
			name = replacement + name;
		}

		if (name[name.length - 1] === '.') {
			name += replacement;
		}

        name = windowsReservedNameRegex.test(name) ? name + replacement : name;

        if (name.length > allowedLength) {
            const extensionIndex = name.lastIndexOf('.');
            if (extensionIndex === -1) {
                name = name.slice(0, allowedLength);
            } else {
                const filename = name.slice(0, extensionIndex);
                const extension = name.slice(extensionIndex);
                name = filename.slice(0, Math.max(1, allowedLength - extension.length)) + extension;
            }
        }


        return name;
    }

    const CHUNK_SIZE = 1024 * 1024 * 1;

    const download = (url, filename) => {
        const stubLink = document.createElement('a');
        stubLink.style.display = 'none';
        stubLink.href = url;
        stubLink.download = filename;
        document.body.appendChild(stubLink);
        stubLink.click();
        document.body.removeChild(stubLink);
    }

    const getAudioPieces = async (baseUrl, start, end) => {
        const headers = {
            'Range': 'bytes=' + start + '-' + end,
            'Referer': location.href
        };
        const result = [];
        console.log('start fetching piece...');
        try {
            const response = await fetch(baseUrl, {
                method: 'GET',
                cache: 'no-cache',
                headers,
                referrerPolicy: 'no-referrer-when-downgrade',
            });
            if (response.status === 416) {
                console.log('reached last piece');
                throw response;
            }
            if (!response.ok) {
                console.error(response);
                throw new Error('Network response was not ok');
            }
            if (!response.headers.get('Content-Range')) {
                console.log('content reached the end');
                const endError = new Error('reached the end');
                endError.status = 204;
                throw endError;
            }
            const audioBuffer = await response.blob();
            result.push(audioBuffer);
            const buffers = await getAudioPieces(baseUrl, end + 1, end + CHUNK_SIZE);
            return result.concat(buffers);
        } catch (err) {
            if (err.status === 204) {
                return result;
            } else if (err.status === 416) {
                const lastPiece = await getLastAudioPiece(baseUrl, start)
                result.push(lastPiece);
                return result;
            } else {
                throw err;
            }
        }
    }

    const getLastAudioPiece = async (baseUrl, start) => {
        const headers = {
            'Range': '' + start + '-',
            'Referer': location.href
        };
        console.log('start fetching last piece...');
        const response = await fetch(baseUrl, {
            method: 'GET',
            cache: 'no-cache',
            headers,
            referrerPolicy: 'no-referrer-when-downgrade',
        })
        if (!response.ok) {
            console.error(response);
            throw new Error('Network response was not ok');
        }
        return await response.blob();
    }

    const getAudio = (baseUrl) => {
        const start = 0;
        const end = CHUNK_SIZE - 1;
        return getAudioPieces(baseUrl, start, end);
    }

    const getInfo = (fieldname) => {
        let info = '';
        const infoMetadataElement = document.head.querySelector(`meta[itemprop="${fieldname}"]`);
        if (infoMetadataElement) {
            info = infoMetadataElement.content;
        }
        if (info.length < 1 && __INITIAL_STATE__) {
            // If we fail to get info from head elements,
            // then we try to get it from __INITIAL_STATE__ or other element
            switch (fieldname) {
                case 'image': {
                    const videoItems = document.querySelectorAll(".video-episode-card.video-episode-card-title-hover");
                    const activeVideoItem = Array.from(videoItems).find(item => item.textContent.includes(getInfo("name")));
                    if (activeVideoItem) {
                        const activeVideoCover = activeVideoItem.querySelector(".activity-image-card.cover-link-image .activity-image-card__image");
                        if (activeVideoCover) {
                            info = activeVideoCover.style.backgroundImage;
                            info = info.replace(/url\("(.+)@.*"\)/, "$1");
                        }
                    }
                    break;
                }
                case 'name':
                    if (__INITIAL_STATE__.videoInfo) {
                        info = __INITIAL_STATE__.videoInfo.title || '';
                    } else if (__INITIAL_STATE__.videoData) {
                        info = __INITIAL_STATE__.videoData.title || '';
                    }
                    break;
                case 'author':
                    if (__INITIAL_STATE__.videoInfo) {
                        info = __INITIAL_STATE__.videoInfo.upName || '';
                    } else if (__INITIAL_STATE__.videoData) {
                        info = __INITIAL_STATE__.videoData.author || __INITIAL_STATE__.videoData.owner?.name || '';
                    }
                    break;
                case 'cid': {
                    const videoData = __INITIAL_STATE__.videoInfo || __INITIAL_STATE__.videoData;
                    if (videoData && Array.isArray(videoData.pages) && videoData.pages.length > 0) {
                        let page = parseInt(__INITIAL_STATE__.p);
                        if (Number.isNaN(page)) {
                            page = 0;
                        } else {
                            page = Math.max(page - 1, 0);
                        }
                        info = `${videoData.pages[page].cid}`;
                        break;
                    }
                    // otherwise, fallback to default handler
                }
                default:
                    if (__INITIAL_STATE__.videoInfo) {
                        info = __INITIAL_STATE__.videoInfo[fieldname] || '';
                    } else if (__INITIAL_STATE__.videoData) {
                        info = __INITIAL_STATE__.videoData[fieldname] || '';
                    }
                    info = `${info}`;
                    break;
            }
        }
        if (fieldname === 'image') {
            // try to get original image url
            try {
                info = info.replace(/(.+)(@.*)/, "$1");
                info = `http:${info}`;
            } catch (e) {
            }
        }
        return info.trim();
    }

    const getLyricsTime = (seconds) => {
        const minutes = Math.floor(seconds / 60);
        const rest = seconds - minutes * 60;
        return `${minutes < 10 ? '0' : ''}${minutes}:${rest < 10 ? '0' : ''}${rest.toFixed(2)}`;
    };

    const getLyrics = async () => {
        if (
            !__INITIAL_STATE__
            || !__INITIAL_STATE__.videoData
            || !__INITIAL_STATE__.videoData.subtitle
            || !Array.isArray(__INITIAL_STATE__.videoData.subtitle.list)
            || __INITIAL_STATE__.videoData.subtitle.list.length === 0
        ) return Promise.resolve(null);
        const defaultLyricsUrl = __INITIAL_STATE__.videoData.subtitle.list[0].subtitle_url;
        const response = await fetch(defaultLyricsUrl.replace('http', 'https'));
        const lyricsObject = await response.json();
        if (!lyricsObject) return null;
        const videoElement = document.querySelector('#bilibiliPlayer .bilibili-player-video bwp-video') || document.querySelector('#bilibiliPlayer .bilibili-player-video video');
        if (!videoElement) return null;
        const totalLength = videoElement.duration;
        const lyrics = lyricsObject.body;
        const lyricsText = lyricsObject.body.reduce((accu, current) => {
            accu += `[${getLyricsTime(current.from)}]${current.content}\r\n`;
            return accu;
        }, '');
        return lyricsText;
    }

    const parse = async () => {
        try {
            const bvid = getInfo("bvid");
            const cid = getInfo("cid");
            // api from: https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/video/videostream_url.md
            const videoMetadataResponse = await fetch(`https://api.bilibili.com/x/player/playurl?bvid=${bvid}&cid=${cid}&fnval=80`, {
                method: 'GET',
                cache: 'no-cache',
                referrerPolicy: 'no-referrer-when-downgrade',
            });
            const videoMetadata = await videoMetadataResponse.json();
            const audioUrlList = videoMetadata.data.dash.audio;
            if (Array.isArray(audioUrlList) && audioUrlList.length > 0) {
                const {baseUrl, mimeType} = audioUrlList[0];
                const audioResult = await getAudio(baseUrl);
                const wholeBlob = new Blob(audioResult, {type: mimeType});
                const buffer = await wholeBlob.arrayBuffer();
                console.log("audio buffer fetched");
                return { buffer, mimeType };
            }
        } catch (err) {
            console.error('There has been a problem with your fetch operation:', err);
        }
        throw new Error("failed to get audio data");
    }

    const buildPluginElement = () => {
        const styles = {
            color: {
                primary: '#00a1d6',
                secondary: '#fb7299',
                lightText: '#f4f4f4'
            },
            spacing: {
                xsmall: '0.25rem',
                small: '0.5rem',
                medium: '1rem',
                large: '2rem',
                xlarge: '3rem'
            }
        };
        const strings = {
            cover: {
                title: '封面'
            },
            infoItems: {
                filename: '文件名',
                title: '标题',
                author: '作者'
            },
            download: {
                idle: '下载音乐',
                processing: '处理中…',
                lyrics: '下载歌词',
                noLyrics: '无歌词'
            }
        }

        const box = document.createElement('div');
        box.isOpen = false;
        // ------------- Container Box START -------------
        const resetBoxStyle = () => {
            box.style.position = 'absolute';
            box.style.left = `-${styles.spacing.xlarge}`;
            box.style.top = 0;
            box.style.transition = box.style.webkitTransition = 'all 0.25s ease';
            box.style.width = box.style.height = styles.spacing.xlarge;
            box.style.borderRadius = styles.spacing.xsmall;
            box.style.opacity = 0.5;
            box.style.cursor = 'pointer';
            box.style.zIndex = 100;
            box.style.boxSizing = 'border-box';
            box.style.overflow = 'hidden';
            box.style.padding = styles.spacing.small;
            box.style.display = 'flex';
            box.style.flexDirection = 'column';
            box.style.boxShadow = "none";
        };
        const openBox = () => {
            box.style.width = '40rem';
            box.style.height = '40rem';
            box.style.backgroundColor = 'white';
            box.style.cursor = 'auto';
            box.style.boxShadow = "0 0 6px gainsboro";

            box.isOpen = true;
            coverImage.src = coverImageUrl = getInfo('image');
        }
        const closeBox = () => {
            resetBoxStyle();
            box.isOpen = false;
        }
        resetBoxStyle();
        box.addEventListener('mouseenter', () => {
            box.style.opacity = 1;
        });
        box.addEventListener('mouseleave', () => {
            if (!box.isOpen) box.style.opacity = 0.5;
        });
        box.addEventListener('click', () => {
            if (!box.isOpen) openBox();
        });
        // ------------- Container Box END -------------

        // ------------- Icon START -------------
        const icon = new DOMParser().parseFromString('<svg id="channel-icon-music" viewBox="0 0 1024 1024" class="icon"><path d="M881.92 460.8a335.36 335.36 0 0 0-334.336-335.104h-73.216A335.616 335.616 0 0 0 139.776 460.8v313.6a18.688 18.688 0 0 0 18.432 18.688h41.984c13.568 46.336 37.888 80.384 88.576 80.384h98.304a37.376 37.376 0 0 0 37.376-36.864l1.28-284.672a36.864 36.864 0 0 0-37.12-37.12h-99.84a111.616 111.616 0 0 0-51.2 12.8V454.4a242.432 242.432 0 0 1 241.664-241.664h67.328A242.176 242.176 0 0 1 787.968 454.4v74.496a110.592 110.592 0 0 0-54.272-14.08h-99.84a36.864 36.864 0 0 0-37.12 37.12v284.672a37.376 37.376 0 0 0 37.376 36.864h98.304c51.2 0 75.008-34.048 88.576-80.384h41.984a18.688 18.688 0 0 0 18.432-18.688z" fill="#45C7DD"></path><path d="m646.1859999999999 792.7090000000001.274-196.096q.046-32.512 32.558-32.466l1.024.001q32.512.045 32.466 32.557l-.274 196.096q-.045 32.512-32.557 32.467l-1.024-.002q-32.512-.045-32.467-32.557ZM307.26800000000003 792.7349999999999l.274-196.096q.045-32.512 32.557-32.467l1.024.002q32.512.045 32.467 32.557l-.274 196.096q-.045 32.512-32.557 32.466l-1.024-.001q-32.512-.045-32.467-32.557Z" fill="#FF5C7A"></path></svg>', 'text/html').getElementById('channel-icon-music');
        icon.style.width = icon.style.height = styles.spacing.large;
        icon.style.flexShrink = 0;
        // ------------- Icon END -------------

        // ------------- Close Button START -------------
        const closeIcon = new DOMParser().parseFromString('<svg id="download__close-button" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M8 6.939l3.182-3.182a.75.75 0 111.061 1.061L9.061 8l3.182 3.182a.75.75 0 11-1.061 1.061L8 9.061l-3.182 3.182a.75.75 0 11-1.061-1.061L6.939 8 3.757 4.818a.75.75 0 111.061-1.061L8 6.939z"></path></svg>', 'text/html').getElementById('download__close-button');
        const closeButton = document.createElement('button');
        closeButton.className = 'bilifont';
        closeButton.style.width = closeButton.style.height = styles.spacing.large;
        closeButton.style.position = 'absolute';
        closeButton.style.left = `max(${styles.spacing.xlarge} + ${styles.spacing.small}, 100% - ${styles.spacing.large} - ${styles.spacing.small})`;
        closeButton.style.top = styles.spacing.small;
        closeButton.style.display = 'flex';
        closeButton.style.alignItems = 'center';
        closeButton.style.justifyContent = 'center';
        closeButton.style.fontSize = '1.5em';
        closeButton.style.color = styles.color.primary;
        closeButton.addEventListener('click', (e) => {
            e.stopPropagation();
            closeBox();
        });
        closeButton.appendChild(closeIcon);
        // ------------- Close Button END -------------

        // ------------- Panel START -------------
        const panel = document.createElement('div');
        panel.style.flex = '1';
        panel.style.margin = '0';
        panel.style.alignSelf = 'stretch';
        panel.style.overflow = 'auto';
        panel.style.marginTop = styles.spacing.small;
        panel.style.paddingTop = styles.spacing.small;
        panel.style.borderTop = `solid 0.125rem ${styles.color.primary}`;
        // ------------- Panel END -------------

        const setTitleStyles = element => {
            element.style.lineHeight = 1.5;
            element.style.margin = 0;
            element.style.padding = 0;
            element.style.color = styles.color.primary;
        };

        let coverImageUrl = getInfo('image');
        console.log('coverImageUrl set to: ', coverImageUrl);

        // ------------- Cover START -------------
        const coverContainer = document.createElement('div');
        coverContainer.style.width = '100%';
        coverContainer.style.marginBottom = styles.spacing.small;
        const coverTitle = document.createElement('h5');
        coverTitle.textContent = strings.cover.title;
        setTitleStyles(coverTitle);
        const coverImage = document.createElement('img');
        coverImage.style.width = '100%';
        coverImage.objectFit = 'contain';
        coverImage.src = coverImageUrl;
        coverContainer.append(coverTitle, coverImage);
        // ------------- Cover END -------------

        // ------------- Info Item START -------------
        const buildInfoItem = (title, text) => {
            const infoContainer = document.createElement('div');
            infoContainer.style.width = '100%';
            infoContainer.style.display = 'flex';
            infoContainer.style.alignItems = 'center';
            infoContainer.style.flexWrap = 'nowrap';
            infoContainer.style.overflow = 'hidden';
            infoContainer.style.marginBottom = styles.spacing.small;
            const infoTitle = document.createElement('h5');
            infoTitle.style.flexBasis = '3em';
            infoTitle.textContent = title;
            setTitleStyles(infoTitle);
            infoTitle.display = 'inline';
            const infoText = document.createElement('input');
            infoText.type = 'text';
            infoText.value = text;
            infoText.style.flex = '1';
            infoText.style.marginLeft = styles.spacing.xsmall;
            infoText.style.background = 'none';
            infoText.style.border = '0';
            infoText.style.borderBottom = `solid 1px ${styles.color.primary}`;
            infoText.style.padding = styles.spacing.xsmall;
            infoContainer.append(infoTitle, infoText);
            infoContainer.textInput = infoText;
            return infoContainer;
        }

        const dummyText = /_哔哩哔哩.+/;
        const titleText = getInfo('name').replace(dummyText, '');
        const filenameItem = buildInfoItem(strings.infoItems.filename, titleText + '.mp3');
        const titleItem = buildInfoItem(strings.infoItems.title, titleText);
        const authorItem = buildInfoItem(strings.infoItems.author, getInfo('author'));
        // ------------- Info Item END -------------

        // ------------- Download Button START -------------
        const downloadButton = document.createElement('button');
        downloadButton.className = "bi-btn";
        downloadButton.textContent = strings.download.idle;
        downloadButton.style.background = 'none';
        downloadButton.style.border = '0';
        downloadButton.style.backgroundColor = styles.color.primary;
        downloadButton.style.color = styles.color.lightText;
        downloadButton.style.width = '45%';
        downloadButton.style.cursor = 'pointer';
        downloadButton.style.textAlign = 'center';
        downloadButton.style.padding = styles.spacing.small;
        downloadButton.style.marginBottom = styles.spacing.small;
        downloadButton.style.transition = downloadButton.style.webkitTransition = 'all 0.25s ease';
        downloadButton.addEventListener('mouseenter', () => {
            downloadButton.style.filter = 'brightness(1.1)';
        });
        downloadButton.addEventListener('mouseleave', () => {
            downloadButton.style.filter = 'none';
        });
        downloadButton.addEventListener('mousedown', () => {
            downloadButton.style.filter = 'brightness(0.9)';
        });
        downloadButton.addEventListener('mouseup', () => {
            downloadButton.style.filter = 'brightness(1.1)';
        });
        downloadButton.addEventListener('click', async (e) => {
            if (downloadButton.disabled) return;
            e.stopPropagation();
            downloadButton.textContent = strings.download.processing;
            downloadButton.disabled = true;
            downloadButton.style.cursor = 'not-allowed';
            try {
                let encoding = false;
                const title = sanitizeStringAsFilename(titleItem.textInput.value);
                const author = sanitizeStringAsFilename(authorItem.textInput.value);
                const { createFFmpeg, fetchFile } = FFmpeg;
                const ffmpeg = createFFmpeg({
                    // log: true,
                    progress: p => {
                        if (encoding) {
                            console.log(p.ratio);
                            downloadButton.textContent = `${strings.download.processing}${(p.ratio / 100).toFixed(0)}%`;
                        }
                    },
                    corePath: "https://unpkg.zhimg.com/@ffmpeg/core-st/dist/ffmpeg-core.js", // https://unpkg.com/@ffmpeg/core-st/dist/ffmpeg-core.js
                    mainName: "main"
                });
                const { buffer, mimeType } = await parse(filenameItem.textInput.value);
                await ffmpeg.load()
                const imageResponse = await fetch(coverImageUrl.replace('http', 'https'));
                const coverImageBlob = await imageResponse.blob();
                const imageFile = await fetchFile(coverImageBlob);
                ffmpeg.FS('writeFile', 'cover.jpg', imageFile);
                console.log('cover image fetched');
                const file = await fetchFile(buffer);
                ffmpeg.FS('writeFile', 'original.mp3', file);
                console.log('encoding file...');
                encoding = true;
                await ffmpeg.run(
                    '-i', 'original.mp3',
                    '-i', 'cover.jpg',
                    '-map', '0',
                    '-map', '1:v',
                    '-ar', '44100',
                    '-b:a', '320k',
                    '-disposition:v:1', 'attached_pic',
                    'out.mp3'
                );
                encoding = false;
                console.log('file encoded...');
                const fileBuffer = await ffmpeg.FS('readFile', 'out.mp3');
                const fileBlob = new Blob([fileBuffer]);
                const fileBlobURL = URL.createObjectURL(fileBlob);
                await ffmpeg.exit();
                await ffmpeg.load();
                await ffmpeg.FS('writeFile', 'out.mp3', fileBuffer);
                console.log('adding metadata...');
                const videoElement = document.querySelector('#bilibiliPlayer .bilibili-player-video bwp-video')
                || document.querySelector('#bilibiliPlayer .bilibili-player-video video')
                || document.querySelector('#bilibili-player bwp-video')
                || document.querySelector('#bilibili-player video');
                await ffmpeg.run(
                    '-i', 'out.mp3',
                    '-codec', 'copy',
                    '-t', `${videoElement.duration}`,
                    '-metadata', `title=${title}`,
                    '-metadata', `artist=${author}`,
                    '-metadata', `publisher=https://${window.location.hostname + window.location.pathname}`,
                    'outWithMetadata.mp3'
                );
                const { buffer: encodedBuffer } = ffmpeg.FS('readFile', 'outWithMetadata.mp3');
                const audioBlob = new Blob([encodedBuffer], {type: mimeType});
                const audioUrl = URL.createObjectURL(audioBlob);
                await download(audioUrl, filenameItem.textInput.value);
            } catch (err) {
                console.error("Failed: ", err);
            } finally {
                downloadButton.textContent = strings.download.idle;
                downloadButton.disabled = false;
                downloadButton.style.cursor = 'pointer';
            }
        });
        const downloadLyricsButton = downloadButton.cloneNode();
        downloadLyricsButton.className = "bi-btn";
        downloadLyricsButton.disabled = true;
        downloadLyricsButton.style.cursor = 'not-allowed';
        downloadLyricsButton.style.marginRight = '10%';
        downloadLyricsButton.textContent = strings.download.noLyrics;
        downloadLyricsButton.addEventListener('mouseenter', () => {
            downloadLyricsButton.style.filter = 'brightness(1.1)';
        });
        downloadLyricsButton.addEventListener('mouseleave', () => {
            downloadLyricsButton.style.filter = 'none';
        });
        downloadLyricsButton.addEventListener('mousedown', () => {
            downloadLyricsButton.style.filter = 'brightness(0.9)';
        });
        downloadLyricsButton.addEventListener('mouseup', () => {
            downloadLyricsButton.style.filter = 'brightness(1.1)';
        });
        let lyricsText = null;
        getLyrics().then(lyrics => {
            if (!lyrics) return;
            lyricsText = lyrics;
            downloadLyricsButton.disabled = false;
            downloadLyricsButton.style.cursor = 'pointer';
            downloadLyricsButton.textContent = strings.download.lyrics;
        })
        downloadLyricsButton.addEventListener('click', (e) => {
            if (downloadLyricsButton.disabled) return;
            e.stopPropagation();
            downloadLyricsButton.textContent = strings.download.processing;
            downloadLyricsButton.disabled = true;
            downloadLyricsButton.style.cursor = 'not-allowed';
            const title = titleItem.textInput.value;
            const author = authorItem.textInput.value;
            lyricsText = `[ti:${title}]\n[ar:${author}]\n${lyricsText}`.trim();
            const lyrics = new Blob([lyricsText], {type: 'text/plain'});
            const lyricsUrl = URL.createObjectURL(lyrics);
            download(lyricsUrl, filenameItem.textInput.value.replace(/\.[^\s\.]+$/, '.lrc'));
            downloadLyricsButton.textContent = strings.download.lyrics;
            downloadButton.disabled = false;
            downloadLyricsButton.style.cursor = 'pointer';
        });
        // ------------- Download Button END -------------
        panel.append(
            coverContainer,
            filenameItem,
            titleItem,
            authorItem,
            downloadLyricsButton,
            downloadButton
        );

        box.append(
            icon,
            closeButton,
            panel
        );

        return box;
    }

    const bilibiliPlayer = document.querySelector('#bilibiliPlayer') || document.querySelector('#bilibili-player');
    if (bilibiliPlayer) {
        const pluginBox = buildPluginElement();
        bilibiliPlayer.appendChild(pluginBox);
    }
})();