- // ==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/ffmpeg@0.11.6/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);
- }
- })();
-
-
-
-
-
-
-
-
-
-
-
-
-