Bilibili Music Extractor

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

Устаревшая версия за 06.12.2022. Перейдите к последней версии.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==UserScript==
// @name         Bilibili Music Extractor
// @namespace    http://tampermonkey.net/
// @version      0.2.7
// @description  从B站上提取带封面的音乐
// @author       ☆
// @include      https://www.bilibili.com/video/*
// @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 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;
        }
        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 () => {
        const playInfoHead = 'window.__playinfo__=';
        const scriptElements = document.head.getElementsByTagName('script');
        const playInfoScript = Array.from(scriptElements).find(script => script.text.trim().startsWith(playInfoHead));
        if (playInfoScript) {
            const playInfoText = playInfoScript.text.trim().substring(playInfoHead.length);
            const playInfo = JSON.parse(playInfoText);
            const audioUrlList = playInfo.data.dash.audio;
            if (Array.isArray(audioUrlList) && audioUrlList.length > 0) {
                try {
                    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);
                }
            }
        }
        return;
    }

    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;
        }
        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;
        };

        const coverImageUrl = getInfo('image');

        // ------------- 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 {
                const title = unescape(encodeURIComponent(titleItem.textInput.value));
                const author = unescape(encodeURIComponent(authorItem.textInput.value));
                const { createFFmpeg, fetchFile } = FFmpeg;
                const ffmpeg = createFFmpeg({
                    //log: true,
                    progress: p => {
                        downloadButton.textContent = `${strings.download.processing}${(p.ratio * 100).toFixed(0)}%`;
                    },
                    //mainName: 'main',
                    //corePath: 'https://unpkg.com/@ffmpeg/[email protected]/dist/ffmpeg-core.js',
                });
                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...');
                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'
                );
                console.log('file encoded...');
                //const fileBuffer = await ffmpeg.FS('readFile', 'out.mp3');
                //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);
    }
})();