Bilibili Music Extractor

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

目前為 2021-08-31 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Bilibili Music Extractor
// @namespace    http://tampermonkey.net/
// @version      0.2.3
// @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 = (baseUrl, start, end) => {
        const headers = {
            'Range': 'bytes=' + start + '-' + end,
            'Referer': location.href
        };
        const result = [];
        console.log('start fetching piece...');
        return fetch(baseUrl, {
            method: 'GET',
            cache: 'no-cache',
            headers,
            referrerPolicy: 'no-referrer-when-downgrade',
        }).then(response => {
            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;
            }
            return response.blob();
        }).then(buffer => {
            result.push(buffer);
            return getAudioPieces(baseUrl, end + 1, end + CHUNK_SIZE);
        }).then(buffers => {
            return result.concat(buffers);
        }).catch(error => {
            if (error.status === 204) {
                return result;
            } else if (error.status === 416) {
                return getLastAudioPiece(baseUrl, start).then(lastPiece => {
                    result.push(lastPiece);
                    return result;
                });
            } else throw error;
        })
    }

    const getLastAudioPiece = (baseUrl, start) => {
        const headers = {
            'Range': '' + start + '-',
            'Referer': location.href
        };
        console.log('start fetching last piece...');
        return fetch(baseUrl, {
            method: 'GET',
            cache: 'no-cache',
            headers,
            referrerPolicy: 'no-referrer-when-downgrade',
        }).then(response => {
            if (!response.ok) {
                console.error(response);
                throw new Error('Network response was not ok');
            }
            return 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 = () => {
        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;
        return fetch(defaultLyricsUrl.replace('http', 'https'))
            .then(response => response.json())
        .then(lyricsObject => {
            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 = () => {
        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) {
                const {baseUrl, mimeType} = audioUrlList[0];
                return getAudio(baseUrl).then(result => {
                    const wholeBlob = new Blob(result, {type: mimeType});

                    return wholeBlob.arrayBuffer().then(buffer => ({ buffer, mimeType }));
                }).catch(error => {
                    console.error('There has been a problem with your fetch operation:', error);
                });
            }
        }
        return Promise.resolve();
    }

    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 = `calc(-${styles.spacing.xlarge} - ${styles.spacing.small})`;
            box.style.top = 0;
            box.style.transition = box.style.webkitTransition = 'all 0.25s ease';
            box.style.width = box.style.height = `calc(${styles.spacing.xlarge} + ${styles.spacing.small})`;
            box.style.border = `solid 0.25rem ${styles.color.primary}`;
            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';
        };
        const openBox = () => {
            box.style.width = '40rem';
            box.style.height = '40rem';
            box.style.backgroundColor = 'white';
            box.style.cursor = 'auto';

            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 svgNamespace = 'http://www.w3.org/2000/svg';
        const icon = document.createElementNS(svgNamespace, 'svg');
        const useIcon = document.createElementNS(svgNamespace, 'use');
        useIcon.setAttributeNS('http://www.w3.org/1999/xlink', "href", "#bili-music");
        icon.style.width = icon.style.height = styles.spacing.large;
        icon.style.flexShrink = 0;
        icon.appendChild(useIcon);
        // ------------- Icon END -------------

        // ------------- Close Button START -------------
        const closeButton = document.createElement('button');
        closeButton.className = 'bilifont bili-icon_sousuo_yichu cancel-icon';
        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();
        });
        // ------------- 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.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.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', (e) => {
            if (downloadButton.disabled) return;
            e.stopPropagation();
            downloadButton.textContent = strings.download.processing;
            downloadButton.disabled = true;
            downloadButton.style.cursor = 'not-allowed';
            const title = unescape(encodeURIComponent(titleItem.textInput.value));
            const author = unescape(encodeURIComponent(authorItem.textInput.value));
            return parse(filenameItem.textInput.value)
                .then(({ buffer, mimeType }) => {
                const {
                    createFFmpeg,
                    fetchFile
                } = FFmpeg;
                const ffmpeg = createFFmpeg();
                const transcode = (blob, filename) => fetchFile(blob)
                .then((file) => {
                    ffmpeg.FS('writeFile', 'original.mp3', file);
                    console.log('encoding file...');
                    return ffmpeg.run(
                        '-i', 'original.mp3',
                        '-i', 'cover.jpg',
                        '-map', '0',
                        '-map', '1:v',
                        '-ar', '44100',
                        '-b:a', '192k',
                        '-disposition:v:1', 'attached_pic',
                        'out.mp3'
                    ).then(() => {
                        console.log('adding metadata...');
                        const videoElement = document.querySelector('#bilibiliPlayer .bilibili-player-video bwp-video') || document.querySelector('#bilibiliPlayer .bilibili-player-video video');

                        return ffmpeg.run(
                            '-i', 'out.mp3',
                            '-codec', 'copy',
                            '-t', `${videoElement.duration}`,
                            '-metadata', `title=${title}`,
                            '-metadata', `artist=${author}`,
                            'outWithMetadata.mp3'
                        );
                    });
                }).then(() => {
                    const data = ffmpeg.FS('readFile', 'outWithMetadata.mp3');
                    return data.buffer;
                });


                ffmpeg.load()
                    .then(() => fetch(coverImageUrl.replace('http', 'https')))
                    .then(response => response.blob())
                    .then(fetchFile)
                .then(imageFile => {
                    ffmpeg.FS('writeFile', 'cover.jpg', imageFile);
                    return transcode(buffer, filenameItem.textInput.value);
                }).then(encodedBuffer => {
                    const audioBlob = new Blob([encodedBuffer], {type: mimeType});
                    const audioUrl = URL.createObjectURL(audioBlob);
                    return download(audioUrl, filenameItem.textInput.value);
                })
                .finally(() => {
                    downloadButton.textContent = strings.download.idle;
                    downloadButton.disabled = false;
                    downloadButton.style.cursor = 'pointer';
                });
            });
        });
        const downloadLyricsButton = downloadButton.cloneNode();
        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');
    if (bilibiliPlayer) {
        const pluginBox = buildPluginElement();
        bilibiliPlayer.appendChild(pluginBox);
    }
})();