Bilibili Music Extractor

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

当前为 2022-08-19 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Bilibili Music Extractor
// @namespace    http://tampermonkey.net/
// @version      0.2.6
// @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 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');
        console.log(icon.style);
        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 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();
        });
        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.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')
                        || document.querySelector('#bilibili-player bwp-video')
                        || document.querySelector('#bilibili-player 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);
                }).catch(e => {
                    console.error(e);
                })
                .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') || document.querySelector('#bilibili-player');
    if (bilibiliPlayer) {
        const pluginBox = buildPluginElement();
        bilibiliPlayer.appendChild(pluginBox);
    }
})();