├── .eslintrc.json ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── bin └── bilibili-get ├── lib ├── downloader.js ├── extractor.js ├── formatter.js ├── index.js └── merger.js ├── package.json └── test.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "parserOptions": { 8 | "ecmaVersion": 2018 9 | }, 10 | "rules": { 11 | "indent": [ 12 | "error", 13 | 2 14 | ], 15 | "linebreak-style": [ 16 | "error", 17 | "unix" 18 | ], 19 | "quotes": [ 20 | "error", 21 | "single" 22 | ], 23 | "semi": [ 24 | "error", 25 | "always" 26 | ], 27 | "no-console": "off" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | package-lock.json 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "9" 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Kamikat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bilibili-get 2 | 3 | [![Build Status](https://travis-ci.org/kamikat/bilibili-get.svg?branch=master)](https://travis-ci.org/kamikat/bilibili-get) 4 | [![Coverage Status](https://coveralls.io/repos/github/kamikat/bilibili-get/badge.svg?branch=master)](https://coveralls.io/github/kamikat/bilibili-get?branch=master) 5 | [![npm version](https://badge.fury.io/js/bilibili-get.svg)](https://badge.fury.io/js/bilibili-get) 6 | 7 | youtube-dl like command-line tool resolving & downloading media files from bilibili. 8 | 9 | ## Features 10 | 11 | - Video quality selection 12 | - Auto-merging video segments 13 | - Premium account bangumi (with `-C` option) 14 | 15 | **WARNING** using proxy with cookie may get your account banned since March 2019. 16 | 17 | bilibili-get supports downloading video from following type of urls: 18 | 19 | | URL | Playlist | Example | 20 | | ------------------------------- | :------: | ------------------------------------------------------- | 21 | | User-uploaded Video | | `https://www.bilibili.com/video/av18182135` | 22 | | User-uploaded Video (multipart) | ✓ | `https://www.bilibili.com/video/av1041170` | 23 | | User-uploaded Video (multipart) | | `https://www.bilibili.com/video/av1041170/index_5.html` | 24 | | Movie Bangumi | | `https://www.bilibili.com/bangumi/play/ss12364/` | 25 | | TV Bangumi (A) | ✓ | `https://bangumi.bilibili.com/anime/5796` | 26 | | Bangumi Episode (A) | | `https://bangumi.bilibili.com/anime/5786/play#100367` | 27 | | TV Bangumi (B) | ✓ | `https://www.bilibili.com/bangumi/play/ss5796` | 28 | | Bangumi Episode (B1) | | `https://www.bilibili.com/bangumi/play/ep100611` | 29 | | Bangumi Episode (B2) | | `https://www.bilibili.com/bangumi/play/ss21769#173345` | 30 | | TV Bangumi (C) | ✓ | `https://www.bilibili.com/bangumi/media/md8892/` | 31 | | URL Redirect | | `https://acg.tv/av106` | 32 | 33 | ## Installation 34 | 35 | Install via NPM: 36 | 37 | ``` 38 | npm install -g bilibili-get 39 | ``` 40 | 41 | bilibili-get uses [aria2](https://aria2.github.io) and [ffmpeg](https://ffmpeg.org) for downloading and video segment merging. 42 | They can be easily installed with a package manager. 43 | 44 | For [Homebrew](https://brew.sh) users: 45 | 46 | ``` 47 | brew install ffmpeg aria2 48 | ``` 49 | 50 | For Linux/Windows users, make sure to have **aria2 > 1.23.0** installed. 51 | 52 | ## Usage 53 | 54 | ``` 55 | bilibili-get https://www.bilibili.com/video/av18182135 56 | ``` 57 | 58 | bilibili-get exposes similar interface with youtube-dl. 59 | 60 | ``` 61 | Usage: bilibili-get [options] 62 | 63 | 64 | Options: 65 | 66 | -o, --output [pattern] set output pattern (default: av%(aid)s %(title)s%(#index&&"\(")s%(index)s%(#index&&"\)")s%(#index_title&&" ")s%(index_title)s.%(ext)s) 67 | -f, --output-format [format] set merged output format [flv/mkv/mp4] 68 | -q, --quality [value] set video quality (default: 0) 69 | -l, --list-formats list available format/quality for video(s) 70 | -x, --http-proxy [server] set HTTP proxy for metadata extractor 71 | -C, --cookie [cookieString] set cookie string 72 | -O, --download-options [key=value] set extra aria2c command-line options (default: ) 73 | -d, --dry-run run the program without any download 74 | -s, --silent suppress video quality output 75 | -V, --version output the version number 76 | -h, --help output usage information 77 | ``` 78 | 79 | The `-o` flag accepts an output template string in [python string formatting method](https://docs.python.org/2/library/stdtypes.html#string-formatting). 80 | Besides typical string formatting options, bilibili-get supports JavaScript expressions replacement expressed by syntax like `%(#1+1)d`. 81 | 82 | And some of the variables are: 83 | 84 | - `aid` - the XXXXXX in avXXXXXX 85 | - `cid` - media resource id 86 | - `ext` - extension name of the output file (can be set by `-f` option) 87 | - `title` - title of video or bangumi 88 | - `index` - part# of a part in video or episode# of an episode in bangumi 89 | - `index_title` - a part name or bangumi episode title 90 | - `episode_id` - id of a bangumi episode 91 | - `bangumi_id` - id of a bangumi 92 | - `quality` - quality id of resolved video 93 | - `format` - format name corresponding to the video quality 94 | 95 | ### Examples 96 | 97 | #### List video quality/format 98 | 99 | ``` 100 | bilibili-get https://www.bilibili.com/video/av18182135 -l 101 | ``` 102 | 103 | #### Quality 104 | 105 | ``` 106 | bilibili-get https://www.bilibili.com/video/av18182135 -q 64 # 720P 107 | bilibili-get https://www.bilibili.com/video/av18182135 -q 80 # 1080P 108 | bilibili-get https://www.bilibili.com/video/av18182135 -q 112 # 1080P 4Kbps 109 | ``` 110 | 111 | #### Merge parts to MKV file 112 | 113 | ``` 114 | bilibili-get https://www.bilibili.com/video/av18182135 -f mkv 115 | ``` 116 | 117 | #### Bangumi 118 | 119 | ``` 120 | bilibili-get -o 'av%(aid)s - %(title)s/%(index)s%(#index_title&&" - ")s%(index_title)s.%(ext)s' -f mkv https://www.bilibili.com/bangumi/play/ss1512 121 | ``` 122 | 123 | #### Cookie of premium account 124 | 125 | ``` 126 | bilibili-get -C 'DedeUserID=XXXXXX; DedeUserID__ckMd5=b199851b45c91f32; sid=XXXXXXXX; SESSDATA=cf33becc%2C1241112410%2A332c1323;' -q 112 -f mkv https://www.bilibili.com/bangumi/play/ss1512 127 | ``` 128 | 129 | #### Multiple connection download 130 | 131 | ``` 132 | bilibili-get https://www.bilibili.com/video/av18182135 -O split=5 -O max-connection-per-server=5 133 | ``` 134 | 135 | #### Download speed limit 136 | 137 | ``` 138 | bilibili-get https://www.bilibili.com/video/av18182135 -O max-download-limit=300K 139 | ``` 140 | 141 | ## License 142 | 143 | (The MIT License) 144 | -------------------------------------------------------------------------------- /bin/bilibili-get: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var debug = require('debug').debug('bilibili:cli'); 4 | var process = require('process'); 5 | var program = require('commander'); 6 | var pkginfo = require('../package.json'); 7 | var commandExists = require('command-exists').sync; 8 | var __main__ = require('../lib'); 9 | var co = require('co'); 10 | 11 | program 12 | .usage('[options] ') 13 | .option('-o, --output [pattern]', 'set output pattern', 'av%(aid)s %(title)s%(#index&&"\\(")s%(index)s%(#index&&"\\)")s%(#index_title&&" ")s%(index_title)s.%(ext)s') 14 | .option('-f, --output-format [format]', 'set merged output format [flv/mkv/mp4]') 15 | .option('-q, --quality [value]', 'set video quality', '112') 16 | .option('-l, --list-formats', 'list available format/quality for video(s)') 17 | .option('-x, --http-proxy [server]', 'set HTTP proxy for metadata extractor') 18 | .option('-C, --cookie [cookieString]', 'set cookie string') 19 | .option('-O, --download-options [key=value]', 'set extra aria2c command-line options', (el, l) => [ ...l, el ], []) 20 | .option('-d, --dry-run', 'run the program without any download') 21 | .option('-s, --silent', 'suppress video quality output') 22 | .version(pkginfo.version) 23 | .parse(process.argv); 24 | 25 | if (!commandExists('aria2c')) { 26 | console.error('ERROR: aria2c: command not found.'); 27 | console.error('Please download and install aria2 with package manager or follow installation\ninstructions on .'); 28 | process.exit(1); 29 | } 30 | 31 | if (!commandExists('ffmpeg')) { 32 | console.error('ERROR: ffmpeg: command not found.'); 33 | console.error('Please download and install ffmpeg with package manager or follow installation\ninstructions from ffmpeg project .'); 34 | process.exit(1); 35 | } 36 | 37 | if (program.args.length !== 1) { 38 | program.outputHelp(); 39 | process.exit(1); 40 | } 41 | 42 | co(__main__(program.args[0], program)).then(() => { 43 | debug('process finished.'); 44 | }).catch((err) => { 45 | debug(err); 46 | console.error(`ERROR: ${err.message}`); 47 | process.exit(1); 48 | }); 49 | 50 | 51 | -------------------------------------------------------------------------------- /lib/downloader.js: -------------------------------------------------------------------------------- 1 | var fs = require('mz/fs'); 2 | var path = require('path'); 3 | var debug = require('debug').debug('bilibili:i:downloader'); 4 | var check = require('debug').debug('bilibili:d:downloader'); 5 | var process = require('process'); 6 | var subprocess = require('child_process'); 7 | var _ = require('lodash'); 8 | 9 | var guessFileExtension = function (url) { 10 | return /https?:\/\/(?:[^/]+\/)+[^.]+(?:\.[^.]+\.)*\.?(.*)(?=\?)/.exec(url)[1]; 11 | }; 12 | 13 | var downloadFiles = function* (taskInfo, { dryRun, print = _.noop, outputDir, downloadOptions = [] }) { 14 | 15 | var segmentFiles = []; 16 | 17 | for (var i = 0, N = taskInfo.durl.length; i++ != N; segmentFiles[i-1] = yield (function* ({ url, size }) { 18 | 19 | print(`downloading video segment ${i}/${N}...`); 20 | 21 | var fileName = `av${taskInfo.aid}-${i}.${guessFileExtension(url)}` 22 | , filePath = path.resolve(outputDir, fileName); 23 | 24 | try { 25 | var stat = yield fs.stat(filePath); 26 | if (stat.size === size && !(yield fs.exists(`${filePath}.aria2`))) { 27 | debug(`file ${filePath} already downloaded.`); 28 | return filePath; 29 | } else { 30 | debug(`file ${filePath} is incomplete.`); 31 | } 32 | } catch (e) { 33 | debug(`file ${filePath} not exists.`); 34 | } 35 | 36 | var aria2cOptions = [ 37 | '--no-conf', 38 | '--console-log-level=error', 39 | '--file-allocation=none', 40 | '--summary-interval=0', 41 | '--download-result=hide', 42 | '--continue', 43 | `--dir=${outputDir}`, 44 | `--out=${fileName}`, 45 | `--referer=${taskInfo.url}`, 46 | ...downloadOptions.map((option) => (option.length === 1 || option.indexOf('=') === 1) ? `-${option}` : `--${option}`), 47 | url 48 | ] 49 | , downloadCommand = `aria2c "${aria2cOptions.join('" "')}"`; 50 | 51 | debug(`executing download command:\n${downloadCommand}`); 52 | 53 | if (dryRun) { 54 | return filePath; 55 | } 56 | 57 | var { status } = subprocess.spawnSync('aria2c', aria2cOptions, { stdio: 'inherit' }); 58 | 59 | process.stderr.write('\33[2K\r'); 60 | 61 | if (status) { 62 | throw new Error(`download command failed with code ${status}.`); 63 | } 64 | 65 | return filePath; 66 | })(taskInfo.durl[i-1])); 67 | 68 | debug('download video segments: success.'); 69 | check(segmentFiles); 70 | 71 | return segmentFiles; 72 | }; 73 | 74 | module.exports = { downloadFiles, guessFileExtension }; 75 | -------------------------------------------------------------------------------- /lib/extractor.js: -------------------------------------------------------------------------------- 1 | var debug = require('debug').debug('bilibili:i:extractor'); 2 | var check = require('debug').debug('bilibili:d:extractor'); 3 | var playUrl = require('bilibili-playurl'); 4 | var request = null; 5 | 6 | var setRequestOptions = function (options = {}) { 7 | options = Object.assign({ 8 | gzip: true, 9 | resolveWithFullResponse: true, 10 | }, options); 11 | options.headers = Object.assign({ 12 | 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36' 13 | }, options.headers); 14 | request = require('request-promise-native').defaults(options); 15 | }; 16 | 17 | setRequestOptions(); 18 | 19 | var REGEX_URL_VIDEO = /^https?:\/\/(?:www\.|bangumi\.|)bilibili\.(?:tv|com)\/(?:video\/av(\d+)(?:\/|\/index_(\d+)\.html)?)(?:\?p=(\d+).*)?$/i; 20 | var REGEX_URL_BGM = /^https?:\/\/(?:www\.|bangumi\.|)bilibili\.(?:tv|com)\/(?:anime\/(\d+)\/play#|bangumi\/play\/ep|bangumi\/play\/ss\d+#)(\d+)\/?(?:\?.*)?$/i; 21 | var REGEX_URL_BGM_LIST = /^https?:\/\/(?:www\.|bangumi\.|)bilibili\.(?:tv|com)\/(?:anime\/(\d+)(?:\/play|\/|)|bangumi\/(?:play\/ss(\d+)|media\/md(\d+))\/?)(?:\?.*)?$/i; 22 | 23 | var parseUrl = function* (url) { 24 | debug(`parsing url: ${url}`); 25 | var type = [ REGEX_URL_VIDEO, REGEX_URL_BGM, REGEX_URL_BGM_LIST ].reverse().reduce((m, regex) => m << 1 | + regex.test(url), 0); 26 | if (type <= 1) { 27 | var resp = yield request.head(url) 28 | , _url = resp.request.uri.href || url; 29 | if (_url == url) { 30 | if (!type) { 31 | throw new Error(`${url} is an invalid url.`); 32 | } 33 | } else { 34 | debug(`parsing url: ${url} -> ${_url}`); 35 | return yield parseUrl(_url); 36 | } 37 | } 38 | var result = Object.assign({ 39 | url: url, 40 | type: type 41 | }, type === 1 && { 42 | video_id: parseInt(REGEX_URL_VIDEO.exec(url)[1]), 43 | part_id: parseInt(REGEX_URL_VIDEO.exec(url)[2] || REGEX_URL_VIDEO.exec(url)[3]) 44 | }, type === 2 && { 45 | bangumi_id: parseInt(REGEX_URL_BGM.exec(url)[1]), 46 | episode_id: parseInt(REGEX_URL_BGM.exec(url)[2]) 47 | }, type === 4 && { 48 | bangumi_id: parseInt(REGEX_URL_BGM_LIST.exec(url)[1] || REGEX_URL_BGM_LIST.exec(url)[2]), 49 | media_id: parseInt(REGEX_URL_BGM_LIST.exec(url)[3]) 50 | }); 51 | debug('parsing url: success.'); 52 | check(result); 53 | return result; 54 | }; 55 | 56 | var fetchWebPage = function* (url, { cookie }) { 57 | debug(`fetching webpage: ${url}`); 58 | try{ 59 | var {body} = yield request.get(url, { 60 | ...(cookie?{headers: {'Cookie': cookie},}:{}), 61 | timeout: 5000}); 62 | }catch(e){ 63 | console.log('timeout, retry...'); 64 | return yield fetchWebPage(url, {cookie}); 65 | } 66 | debug('fetching webpage: success.'); 67 | return body; 68 | }; 69 | 70 | var checkError = function (page) { 71 | var error = /options=({.*})/g.exec(page.replace(/(\n| )/g, '')); 72 | if (error) { 73 | var { type, data } = eval(`(${error[1]})`) 74 | , reason = ({ 75 | 701: `conflicted (try ${data.url})`, 76 | 702: 'not found', 77 | 703: 'waiting for publish', 78 | 704: 'waiting for review', 79 | 705: 'authentication required (set a cookie with `-C`)' 80 | }); 81 | throw new Error(`${type}: ${reason[data.code]}`); 82 | } 83 | }; 84 | 85 | var checkError1 = function (state) { 86 | if (state.error && state.error.code) { 87 | var code = Math.abs(state.error.trueCode) 88 | , reason = ({ 89 | 403: 'articleError: authentication required, please set a cookie with `-C` and try again', 90 | 404: 'articleError: not found' 91 | }); 92 | throw new Error(`${reason[code] || 'unknown'} [code=${state.error.trueCode}, message="${state.error.message}"]`); 93 | } 94 | }; 95 | 96 | var findVideoInfo = function* ({ video_id, part_id, cookie }) { 97 | debug(`extracting video info: av${video_id}`); 98 | var data = yield fetchWebPage(`https://www.bilibili.com/video/av${video_id}/` + (part_id ? `index_${part_id}.html` : ''), { cookie }); 99 | check(data); 100 | var videoInfo = {}; 101 | var state_str = /window\.__INITIAL_STATE__=({.*?});/.exec(data.replace(/(\n| )/g, '')) 102 | , state = state_str && eval(`(${state_str[1]})`); 103 | if (state) { 104 | check(state); 105 | checkError1(state); 106 | videoInfo = Object.assign(videoInfo, { 107 | info: { 108 | title: state.videoData.title, 109 | creator: state.videoData.owner.name, 110 | creator_id: state.videoData.owner.mid, 111 | created_at: new Date(state.videoData.pubdate) 112 | }, 113 | parts: state.videoData.pages.map(({ cid, page, part }) => ({ 114 | aid: video_id, 115 | cid: parseInt(cid), 116 | part_id: page, 117 | index: `${page}`, 118 | index_title: part, 119 | })) 120 | }); 121 | } else { 122 | checkError(data); 123 | videoInfo = Object.assign(videoInfo, { 124 | info: { 125 | title: /

]*title="([^"]*)/.exec(data)[1], 126 | creator: /]*card="([^"]*)/.exec(data)[1], 127 | creator_id: /]*mid="([^"]*)/.exec(data)[1], 128 | created_at: /