import functools
import hashlib
import itertools
+import json
import math
+import re
import time
-import urllib.error
import urllib.parse
from .common import InfoExtractor, SearchInfoExtractor
from ..dependencies import Cryptodome
+from ..networking.exceptions import HTTPError
from ..utils import (
ExtractorError,
GeoRestrictedError,
InAdvancePagedList,
OnDemandPagedList,
+ bool_or_none,
+ clean_html,
filter_dict,
float_or_none,
format_field,
+ get_element_by_class,
int_or_none,
join_nonempty,
make_archive_id,
unsmuggle_url,
url_or_none,
urlencode_postdata,
+ variadic,
)
class BilibiliBaseIE(InfoExtractor):
+ _FORMAT_ID_RE = re.compile(r'-(\d+)\.m4s\?')
+
def extract_formats(self, play_info):
format_names = {
r['quality']: traverse_obj(r, 'new_description', 'display_desc')
for r in traverse_obj(play_info, ('support_formats', lambda _, v: v['quality']))
}
- audios = traverse_obj(play_info, ('dash', 'audio', ...))
+ audios = traverse_obj(play_info, ('dash', (None, 'dolby'), 'audio', ..., {dict}))
flac_audio = traverse_obj(play_info, ('dash', 'flac', 'audio'))
if flac_audio:
audios.append(flac_audio)
formats = [{
'url': traverse_obj(audio, 'baseUrl', 'base_url', 'url'),
'ext': mimetype2ext(traverse_obj(audio, 'mimeType', 'mime_type')),
- 'acodec': audio.get('codecs'),
+ 'acodec': traverse_obj(audio, ('codecs', {str.lower})),
'vcodec': 'none',
'tbr': float_or_none(audio.get('bandwidth'), scale=1000),
- 'filesize': int_or_none(audio.get('size'))
+ 'filesize': int_or_none(audio.get('size')),
+ 'format_id': str_or_none(audio.get('id')),
} for audio in audios]
formats.extend({
'height': int_or_none(video.get('height')),
'vcodec': video.get('codecs'),
'acodec': 'none' if audios else None,
+ 'dynamic_range': {126: 'DV', 125: 'HDR10'}.get(int_or_none(video.get('id'))),
'tbr': float_or_none(video.get('bandwidth'), scale=1000),
'filesize': int_or_none(video.get('size')),
'quality': int_or_none(video.get('id')),
+ 'format_id': traverse_obj(
+ video, (('baseUrl', 'base_url'), {self._FORMAT_ID_RE.search}, 1),
+ ('id', {str_or_none}), get_all=False),
'format': format_names.get(video.get('id')),
} for video in traverse_obj(play_info, ('dash', 'video', ...)))
return formats
+ def _download_playinfo(self, video_id, cid):
+ return self._download_json(
+ 'https://api.bilibili.com/x/player/playurl', video_id,
+ query={'bvid': video_id, 'cid': cid, 'fnval': 4048},
+ note=f'Downloading video formats for cid {cid}')['data']
+
def json2srt(self, json_data):
srt_data = ''
for idx, line in enumerate(json_data.get('body') or []):
f'{line["content"]}\n\n')
return srt_data
- def _get_subtitles(self, video_id, aid, cid):
+ def _get_subtitles(self, video_id, cid, aid=None):
subtitles = {
'danmaku': [{
'ext': 'xml',
}]
}
- video_info_json = self._download_json(f'https://api.bilibili.com/x/player/v2?aid={aid}&cid={cid}', video_id)
- for s in traverse_obj(video_info_json, ('data', 'subtitle', 'subtitles', ...)):
+ subtitle_info = traverse_obj(self._download_json(
+ 'https://api.bilibili.com/x/player/v2', video_id,
+ query={'aid': aid, 'cid': cid} if aid else {'bvid': video_id, 'cid': cid},
+ note=f'Extracting subtitle info {cid}'), ('data', 'subtitle'))
+ subs_list = traverse_obj(subtitle_info, ('subtitles', lambda _, v: v['subtitle_url'] and v['lan']))
+ if not subs_list and traverse_obj(subtitle_info, 'allow_submit'):
+ if not self._get_cookies('https://api.bilibili.com').get('SESSDATA'): # no login session cookie
+ self.report_warning(f'CC subtitles (if any) are only visible when logged in. {self._login_hint()}', only_once=True)
+ for s in subs_list:
subtitles.setdefault(s['lan'], []).append({
'ext': 'srt',
'data': self.json2srt(self._download_json(s['subtitle_url'], video_id))
for entry in traverse_obj(season_info, (
'result', 'main_section', 'episodes',
lambda _, v: url_or_none(v['share_url']) and v['id'])):
- yield self.url_result(entry['share_url'], BiliBiliBangumiIE, f'ep{entry["id"]}')
+ yield self.url_result(entry['share_url'], BiliBiliBangumiIE, str_or_none(entry.get('id')))
+
+ def _get_divisions(self, video_id, graph_version, edges, edge_id, cid_edges=None):
+ cid_edges = cid_edges or {}
+ division_data = self._download_json(
+ 'https://api.bilibili.com/x/stein/edgeinfo_v2', video_id,
+ query={'graph_version': graph_version, 'edge_id': edge_id, 'bvid': video_id},
+ note=f'Extracting divisions from edge {edge_id}')
+ edges.setdefault(edge_id, {}).update(
+ traverse_obj(division_data, ('data', 'story_list', lambda _, v: v['edge_id'] == edge_id, {
+ 'title': ('title', {str}),
+ 'cid': ('cid', {int_or_none}),
+ }), get_all=False))
+
+ edges[edge_id].update(traverse_obj(division_data, ('data', {
+ 'title': ('title', {str}),
+ 'choices': ('edges', 'questions', ..., 'choices', ..., {
+ 'edge_id': ('id', {int_or_none}),
+ 'cid': ('cid', {int_or_none}),
+ 'text': ('option', {str}),
+ }),
+ })))
+ # use dict to combine edges that use the same video section (same cid)
+ cid_edges.setdefault(edges[edge_id]['cid'], {})[edge_id] = edges[edge_id]
+ for choice in traverse_obj(edges, (edge_id, 'choices', ...)):
+ if choice['edge_id'] not in edges:
+ edges[choice['edge_id']] = {'cid': choice['cid']}
+ self._get_divisions(video_id, graph_version, edges, choice['edge_id'], cid_edges=cid_edges)
+ return cid_edges
+
+ def _get_interactive_entries(self, video_id, cid, metainfo):
+ graph_version = traverse_obj(
+ self._download_json(
+ 'https://api.bilibili.com/x/player/wbi/v2', video_id,
+ 'Extracting graph version', query={'bvid': video_id, 'cid': cid}),
+ ('data', 'interaction', 'graph_version', {int_or_none}))
+ cid_edges = self._get_divisions(video_id, graph_version, {1: {'cid': cid}}, 1)
+ for cid, edges in cid_edges.items():
+ play_info = self._download_playinfo(video_id, cid)
+ yield {
+ **metainfo,
+ 'id': f'{video_id}_{cid}',
+ 'title': f'{metainfo.get("title")} - {list(edges.values())[0].get("title")}',
+ 'formats': self.extract_formats(play_info),
+ 'description': f'{json.dumps(edges, ensure_ascii=False)}\n{metainfo.get("description", "")}',
+ 'duration': float_or_none(play_info.get('timelength'), scale=1000),
+ 'subtitles': self.extract_subtitles(video_id, cid),
+ }
class BiliBiliIE(BilibiliBaseIE):
- _VALID_URL = r'https?://www\.bilibili\.com/(?:video/|festival/\w+\?(?:[^#]*&)?bvid=)[aAbB][vV](?P<id>[^/?#&]+)'
+ _VALID_URL = r'https?://(?:www\.)?bilibili\.com/(?:video/|festival/\w+\?(?:[^#]*&)?bvid=)[aAbB][vV](?P<id>[^/?#&]+)'
_TESTS = [{
'url': 'https://www.bilibili.com/video/BV13x41117TL',
'view_count': int,
},
}, {
- # old av URL version
+ 'note': 'old av URL version',
'url': 'http://www.bilibili.com/video/av1074402/',
'info_dict': {
'thumbnail': r're:^https?://.*\.(jpg|jpeg)$',
'id': 'BV1bK411W797_p1',
'ext': 'mp4',
'title': '物语中的人物是如何吐槽自己的OP的 p01 Staple Stable/战场原+羽川',
- 'tags': 'count:11',
+ 'tags': 'count:10',
'timestamp': 1589601697,
'thumbnail': r're:^https?://.*\.(jpg|jpeg|png)$',
'uploader': '打牌还是打桩',
'id': 'BV1bK411W797_p1',
'ext': 'mp4',
'title': '物语中的人物是如何吐槽自己的OP的 p01 Staple Stable/战场原+羽川',
- 'tags': 'count:11',
+ 'tags': 'count:10',
'timestamp': 1589601697,
'thumbnail': r're:^https?://.*\.(jpg|jpeg|png)$',
'uploader': '打牌还是打桩',
'description': 'md5:afde2b7ba9025c01d9e3dde10de221e4',
'duration': 313.557,
'upload_date': '20220709',
- 'uploader': '小夫Tech',
+ 'uploader': '小夫太渴',
'timestamp': 1657347907,
'uploader_id': '1326814124',
'comment_count': int,
'thumbnail': r're:^https?://.*\.(jpg|jpeg|png)$',
},
'params': {'skip_download': True},
+ }, {
+ 'note': 'interactive/split-path video',
+ 'url': 'https://www.bilibili.com/video/BV1af4y1H7ga/',
+ 'info_dict': {
+ 'id': 'BV1af4y1H7ga',
+ 'title': '【互动游戏】花了大半年时间做的自我介绍~请查收!!',
+ 'timestamp': 1630500414,
+ 'upload_date': '20210901',
+ 'description': 'md5:01113e39ab06e28042d74ac356a08786',
+ 'tags': list,
+ 'uploader': '钉宫妮妮Ninico',
+ 'duration': 1503,
+ 'uploader_id': '8881297',
+ 'comment_count': int,
+ 'view_count': int,
+ 'like_count': int,
+ 'thumbnail': r're:^https?://.*\.(jpg|jpeg|png)$',
+ },
+ 'playlist_count': 33,
+ 'playlist': [{
+ 'info_dict': {
+ 'id': 'BV1af4y1H7ga_400950101',
+ 'ext': 'mp4',
+ 'title': '【互动游戏】花了大半年时间做的自我介绍~请查收!! - 听见猫猫叫~',
+ 'timestamp': 1630500414,
+ 'upload_date': '20210901',
+ 'description': 'md5:db66ac7a2813a94b8291dbce990cc5b2',
+ 'tags': list,
+ 'uploader': '钉宫妮妮Ninico',
+ 'duration': 11.605,
+ 'uploader_id': '8881297',
+ 'comment_count': int,
+ 'view_count': int,
+ 'like_count': int,
+ 'thumbnail': r're:^https?://.*\.(jpg|jpeg|png)$',
+ },
+ }],
+ }, {
+ 'note': '301 redirect to bangumi link',
+ 'url': 'https://www.bilibili.com/video/BV1TE411f7f1',
+ 'info_dict': {
+ 'id': '288525',
+ 'title': '李永乐老师 钱学森弹道和乘波体飞行器是什么?',
+ 'ext': 'mp4',
+ 'series': '我和我的祖国',
+ 'series_id': '4780',
+ 'season': '幕后纪实',
+ 'season_id': '28609',
+ 'season_number': 1,
+ 'episode': '钱学森弹道和乘波体飞行器是什么?',
+ 'episode_id': '288525',
+ 'episode_number': 105,
+ 'duration': 1183.957,
+ 'timestamp': 1571648124,
+ 'upload_date': '20191021',
+ 'thumbnail': r're:^https?://.*\.(jpg|jpeg|png)$',
+ },
+ }, {
+ 'url': 'https://www.bilibili.com/video/BV1jL41167ZG/',
+ 'info_dict': {
+ 'id': 'BV1jL41167ZG',
+ 'title': '一场大火引发的离奇死亡!古典推理经典短篇集《不可能犯罪诊断书》!',
+ 'ext': 'mp4',
+ },
+ 'skip': 'supporter-only video',
+ }, {
+ 'url': 'https://www.bilibili.com/video/BV1Ks411f7aQ/',
+ 'info_dict': {
+ 'id': 'BV1Ks411f7aQ',
+ 'title': '【BD1080P】狼与香辛料I【华盟】',
+ 'ext': 'mp4',
+ },
+ 'skip': 'login required',
+ }, {
+ 'url': 'https://www.bilibili.com/video/BV1GJ411x7h7/',
+ 'info_dict': {
+ 'id': 'BV1GJ411x7h7',
+ 'title': '【官方 MV】Never Gonna Give You Up - Rick Astley',
+ 'ext': 'mp4',
+ },
+ 'skip': 'geo-restricted',
}]
def _real_extract(self, url):
video_id = self._match_id(url)
- webpage = self._download_webpage(url, video_id)
+ webpage, urlh = self._download_webpage_handle(url, video_id)
+ if not self._match_valid_url(urlh.url):
+ return self.url_result(urlh.url)
+
initial_state = self._search_json(r'window\.__INITIAL_STATE__\s*=', webpage, 'initial state', video_id)
is_festival = 'videoData' not in initial_state
if is_festival:
video_data = initial_state['videoInfo']
else:
- play_info = self._search_json(r'window\.__playinfo__\s*=', webpage, 'play info', video_id)['data']
+ play_info_obj = self._search_json(
+ r'window\.__playinfo__\s*=', webpage, 'play info', video_id, fatal=False)
+ if not play_info_obj:
+ if traverse_obj(initial_state, ('error', 'trueCode')) == -403:
+ self.raise_login_required()
+ if traverse_obj(initial_state, ('error', 'trueCode')) == -404:
+ raise ExtractorError(
+ 'This video may be deleted or geo-restricted. '
+ 'You might want to try a VPN or a proxy server (with --proxy)', expected=True)
+ play_info = traverse_obj(play_info_obj, ('data', {dict}))
+ if not play_info:
+ if traverse_obj(play_info_obj, 'code') == 87007:
+ toast = get_element_by_class('tips-toast', webpage) or ''
+ msg = clean_html(
+ f'{get_element_by_class("belongs-to", toast) or ""},'
+ + (get_element_by_class('level', toast) or ''))
+ raise ExtractorError(
+ f'This is a supporter-only video: {msg}. {self._login_hint()}', expected=True)
+ raise ExtractorError('Failed to extract play info')
video_data = initial_state['videoData']
video_id, title = video_data['bvid'], video_data.get('title')
festival_info = {}
if is_festival:
- play_info = self._download_json(
- 'https://api.bilibili.com/x/player/playurl', video_id,
- query={'bvid': video_id, 'cid': cid, 'fnval': 4048},
- note='Extracting festival video formats')['data']
+ play_info = self._download_playinfo(video_id, cid)
festival_info = traverse_obj(initial_state, {
'uploader': ('videoInfo', 'upName'),
'thumbnail': ('sectionEpisodes', lambda _, v: v['bvid'] == video_id, 'cover'),
}, get_all=False)
- return {
+ metainfo = {
**traverse_obj(initial_state, {
'uploader': ('upData', 'name'),
'uploader_id': ('upData', 'mid', {str_or_none}),
'comment_count': ('stat', 'reply', {int_or_none}),
}, get_all=False),
'id': f'{video_id}{format_field(part_id, None, "_p%d")}',
- 'formats': self.extract_formats(play_info),
'_old_archive_ids': [make_archive_id(self, old_video_id)] if old_video_id else None,
'title': title,
- 'duration': float_or_none(play_info.get('timelength'), scale=1000),
- 'chapters': self._get_chapters(aid, cid),
- 'subtitles': self.extract_subtitles(video_id, aid, cid),
- '__post_extractor': self.extract_comments(aid),
'http_headers': {'Referer': url},
}
+ is_interactive = traverse_obj(video_data, ('rights', 'is_stein_gate'))
+ if is_interactive:
+ return self.playlist_result(
+ self._get_interactive_entries(video_id, cid, metainfo), **metainfo, **{
+ 'duration': traverse_obj(initial_state, ('videoData', 'duration', {int_or_none})),
+ '__post_extractor': self.extract_comments(aid),
+ })
+ else:
+ return {
+ **metainfo,
+ 'duration': float_or_none(play_info.get('timelength'), scale=1000),
+ 'chapters': self._get_chapters(aid, cid),
+ 'subtitles': self.extract_subtitles(video_id, cid),
+ 'formats': self.extract_formats(play_info),
+ '__post_extractor': self.extract_comments(aid),
+ }
+
class BiliBiliBangumiIE(BilibiliBaseIE):
- _VALID_URL = r'https?://(?:www\.)?bilibili\.com/bangumi/play/(?P<id>ep\d+)'
+ _VALID_URL = r'https?://(?:www\.)?bilibili\.com/bangumi/play/ep(?P<id>\d+)'
_TESTS = [{
+ 'url': 'https://www.bilibili.com/bangumi/play/ep21495/',
+ 'info_dict': {
+ 'id': '21495',
+ 'ext': 'mp4',
+ 'series': '悠久之翼',
+ 'series_id': '774',
+ 'season': '第二季',
+ 'season_id': '1182',
+ 'season_number': 2,
+ 'episode': 'forever/ef',
+ 'episode_id': '21495',
+ 'episode_number': 12,
+ 'title': '12 forever/ef',
+ 'duration': 1420.791,
+ 'timestamp': 1320412200,
+ 'upload_date': '20111104',
+ 'thumbnail': r're:^https?://.*\.(jpg|jpeg|png)$',
+ },
+ }, {
'url': 'https://www.bilibili.com/bangumi/play/ep267851',
'info_dict': {
'id': '267851',
'ext': 'mp4',
'series': '鬼灭之刃',
'series_id': '4358',
- 'season': '鬼灭之刃',
+ 'season': '立志篇',
'season_id': '26801',
'season_number': 1,
'episode': '残酷',
'upload_date': '20190406',
'thumbnail': r're:^https?://.*\.(jpg|jpeg|png)$'
},
- 'skip': 'According to the copyright owner\'s request, you may only watch the video after you are premium member.'
+ 'skip': 'Geo-restricted',
+ }, {
+ 'note': 'a making-of which falls outside main section',
+ 'url': 'https://www.bilibili.com/bangumi/play/ep345120',
+ 'info_dict': {
+ 'id': '345120',
+ 'ext': 'mp4',
+ 'series': '鬼灭之刃',
+ 'series_id': '4358',
+ 'season': '立志篇',
+ 'season_id': '26801',
+ 'season_number': 1,
+ 'episode': '炭治郎篇',
+ 'episode_id': '345120',
+ 'episode_number': 27,
+ 'title': '#1 炭治郎篇',
+ 'duration': 1922.129,
+ 'timestamp': 1602853860,
+ 'upload_date': '20201016',
+ 'thumbnail': r're:^https?://.*\.(jpg|jpeg|png)$'
+ },
}]
def _real_extract(self, url):
- video_id = self._match_id(url)
- episode_id = video_id[2:]
- webpage = self._download_webpage(url, video_id)
+ episode_id = self._match_id(url)
+ webpage = self._download_webpage(url, episode_id)
if '您所在的地区无法观看本片' in webpage:
raise GeoRestrictedError('This video is restricted')
headers = {'Referer': url, **self.geo_verification_headers()}
play_info = self._download_json(
- 'https://api.bilibili.com/pgc/player/web/v2/playurl', video_id,
+ 'https://api.bilibili.com/pgc/player/web/v2/playurl', episode_id,
'Extracting episode', query={'fnval': '4048', 'ep_id': episode_id},
headers=headers)
premium_only = play_info.get('code') == -10403
self.raise_login_required('This video is for premium members only')
bangumi_info = self._download_json(
- 'https://api.bilibili.com/pgc/view/web/season', video_id, 'Get episode details',
+ 'https://api.bilibili.com/pgc/view/web/season', episode_id, 'Get episode details',
query={'ep_id': episode_id}, headers=headers)['result']
episode_number, episode_info = next((
(idx, ep) for idx, ep in enumerate(traverse_obj(
- bangumi_info, ('episodes', ..., {dict})), 1)
+ bangumi_info, (('episodes', ('section', ..., 'episodes')), ..., {dict})), 1)
if str_or_none(ep.get('id')) == episode_id), (1, {}))
season_id = bangumi_info.get('season_id')
- season_number = season_id and next((
- idx + 1 for idx, e in enumerate(
+ season_number, season_title = season_id and next((
+ (idx + 1, e.get('season_title')) for idx, e in enumerate(
traverse_obj(bangumi_info, ('seasons', ...)))
if e.get('season_id') == season_id
- ), None)
+ ), (None, None))
aid = episode_info.get('aid')
return {
- 'id': video_id,
+ 'id': episode_id,
'formats': formats,
**traverse_obj(bangumi_info, {
'series': ('series', 'series_title', {str}),
'series_id': ('series', 'series_id', {str_or_none}),
'thumbnail': ('square_cover', {url_or_none}),
}),
- 'title': join_nonempty('title', 'long_title', delim=' ', from_dict=episode_info),
- 'episode': episode_info.get('long_title'),
+ **traverse_obj(episode_info, {
+ 'episode': ('long_title', {str}),
+ 'episode_number': ('title', {int_or_none}, {lambda x: x or episode_number}),
+ 'timestamp': ('pub_time', {int_or_none}),
+ 'title': {lambda v: v and join_nonempty('title', 'long_title', delim=' ', from_dict=v)},
+ }),
'episode_id': episode_id,
- 'episode_number': int_or_none(episode_info.get('title')) or episode_number,
+ 'season': str_or_none(season_title),
'season_id': str_or_none(season_id),
'season_number': season_number,
- 'timestamp': int_or_none(episode_info.get('pub_time')),
'duration': float_or_none(play_info.get('timelength'), scale=1000),
- 'subtitles': self.extract_subtitles(video_id, aid, episode_info.get('cid')),
+ 'subtitles': self.extract_subtitles(episode_id, episode_info.get('cid'), aid=aid),
'__post_extractor': self.extract_comments(aid),
'http_headers': headers,
}
class BiliBiliBangumiMediaIE(BilibiliBaseIE):
- _VALID_URL = r'https?://www\.bilibili\.com/bangumi/media/md(?P<id>\d+)'
+ _VALID_URL = r'https?://(?:www\.)?bilibili\.com/bangumi/media/md(?P<id>\d+)'
_TESTS = [{
'url': 'https://www.bilibili.com/bangumi/media/md24097891',
'info_dict': {
'id': '24097891',
+ 'title': 'CAROLE & TUESDAY',
+ 'description': 'md5:42417ad33d1eaa1c93bfd2dd1626b829',
},
'playlist_mincount': 25,
+ }, {
+ 'url': 'https://www.bilibili.com/bangumi/media/md1565/',
+ 'info_dict': {
+ 'id': '1565',
+ 'title': '攻壳机动队 S.A.C. 2nd GIG',
+ 'description': 'md5:46cac00bafd645b97f4d6df616fc576d',
+ },
+ 'playlist_count': 26,
+ 'playlist': [{
+ 'info_dict': {
+ 'id': '68540',
+ 'ext': 'mp4',
+ 'series': '攻壳机动队',
+ 'series_id': '1077',
+ 'season': '第二季',
+ 'season_id': '1565',
+ 'season_number': 2,
+ 'episode': '再启动 REEMBODY',
+ 'episode_id': '68540',
+ 'episode_number': 1,
+ 'title': '1 再启动 REEMBODY',
+ 'duration': 1525.777,
+ 'timestamp': 1425074413,
+ 'upload_date': '20150227',
+ 'thumbnail': r're:^https?://.*\.(jpg|jpeg|png)$'
+ },
+ }],
}]
def _real_extract(self, url):
media_id = self._match_id(url)
webpage = self._download_webpage(url, media_id)
- ss_id = self._search_json(
- r'window\.__INITIAL_STATE__\s*=', webpage, 'initial_state', media_id)['mediaInfo']['season_id']
- return self.playlist_result(self._get_episodes_from_season(ss_id, url), media_id)
+ initial_state = self._search_json(
+ r'window\.__INITIAL_STATE__\s*=', webpage, 'initial_state', media_id)
+ ss_id = initial_state['mediaInfo']['season_id']
+
+ return self.playlist_result(
+ self._get_episodes_from_season(ss_id, url), media_id,
+ **traverse_obj(initial_state, ('mediaInfo', {
+ 'title': ('title', {str}),
+ 'description': ('evaluate', {str}),
+ })))
class BiliBiliBangumiSeasonIE(BilibiliBaseIE):
- _VALID_URL = r'(?x)https?://www\.bilibili\.com/bangumi/play/ss(?P<id>\d+)'
+ _VALID_URL = r'(?x)https?://(?:www\.)?bilibili\.com/bangumi/play/ss(?P<id>\d+)'
_TESTS = [{
'url': 'https://www.bilibili.com/bangumi/play/ss26801',
'info_dict': {
- 'id': '26801'
+ 'id': '26801',
+ 'title': '鬼灭之刃',
+ 'description': 'md5:e2cc9848b6f69be6db79fc2a82d9661b',
},
'playlist_mincount': 26
+ }, {
+ 'url': 'https://www.bilibili.com/bangumi/play/ss2251',
+ 'info_dict': {
+ 'id': '2251',
+ 'title': '玲音',
+ 'description': 'md5:1fd40e3df4c08d4d9d89a6a34844bdc4',
+ },
+ 'playlist_count': 13,
+ 'playlist': [{
+ 'info_dict': {
+ 'id': '50188',
+ 'ext': 'mp4',
+ 'series': '玲音',
+ 'series_id': '1526',
+ 'season': 'TV',
+ 'season_id': '2251',
+ 'season_number': 1,
+ 'episode': 'WEIRD',
+ 'episode_id': '50188',
+ 'episode_number': 1,
+ 'title': '1 WEIRD',
+ 'duration': 1436.992,
+ 'timestamp': 1343185080,
+ 'upload_date': '20120725',
+ 'thumbnail': r're:^https?://.*\.(jpg|jpeg|png)$'
+ },
+ }],
}]
def _real_extract(self, url):
ss_id = self._match_id(url)
+ webpage = self._download_webpage(url, ss_id)
+ metainfo = traverse_obj(
+ self._search_json(r'<script[^>]+type="application/ld\+json"[^>]*>', webpage, 'info', ss_id),
+ ('itemListElement', ..., {
+ 'title': ('name', {str}),
+ 'description': ('description', {str}),
+ }), get_all=False)
+
+ return self.playlist_result(self._get_episodes_from_season(ss_id, url), ss_id, **metainfo)
+
+
+class BilibiliCheeseBaseIE(BilibiliBaseIE):
+ _HEADERS = {'Referer': 'https://www.bilibili.com/'}
+
+ def _extract_episode(self, season_info, ep_id):
+ episode_info = traverse_obj(season_info, (
+ 'episodes', lambda _, v: v['id'] == int(ep_id)), get_all=False)
+ aid, cid = episode_info['aid'], episode_info['cid']
- return self.playlist_result(self._get_episodes_from_season(ss_id, url), ss_id)
+ if traverse_obj(episode_info, 'ep_status') == -1:
+ raise ExtractorError('This course episode is not yet available.', expected=True)
+ if not traverse_obj(episode_info, 'playable'):
+ self.raise_login_required('You need to purchase the course to download this episode')
+
+ play_info = self._download_json(
+ 'https://api.bilibili.com/pugv/player/web/playurl', ep_id,
+ query={'avid': aid, 'cid': cid, 'ep_id': ep_id, 'fnval': 16, 'fourk': 1},
+ headers=self._HEADERS, note='Downloading playinfo')['data']
+
+ return {
+ 'id': str_or_none(ep_id),
+ 'episode_id': str_or_none(ep_id),
+ 'formats': self.extract_formats(play_info),
+ 'extractor_key': BilibiliCheeseIE.ie_key(),
+ 'extractor': BilibiliCheeseIE.IE_NAME,
+ 'webpage_url': f'https://www.bilibili.com/cheese/play/ep{ep_id}',
+ **traverse_obj(episode_info, {
+ 'episode': ('title', {str}),
+ 'title': {lambda v: v and join_nonempty('index', 'title', delim=' - ', from_dict=v)},
+ 'alt_title': ('subtitle', {str}),
+ 'duration': ('duration', {int_or_none}),
+ 'episode_number': ('index', {int_or_none}),
+ 'thumbnail': ('cover', {url_or_none}),
+ 'timestamp': ('release_date', {int_or_none}),
+ 'view_count': ('play', {int_or_none}),
+ }),
+ **traverse_obj(season_info, {
+ 'uploader': ('up_info', 'uname', {str}),
+ 'uploader_id': ('up_info', 'mid', {str_or_none}),
+ }),
+ 'subtitles': self.extract_subtitles(ep_id, cid, aid=aid),
+ '__post_extractor': self.extract_comments(aid),
+ 'http_headers': self._HEADERS,
+ }
+
+ def _download_season_info(self, query_key, video_id):
+ return self._download_json(
+ f'https://api.bilibili.com/pugv/view/web/season?{query_key}={video_id}', video_id,
+ headers=self._HEADERS, note='Downloading season info')['data']
+
+
+class BilibiliCheeseIE(BilibiliCheeseBaseIE):
+ _VALID_URL = r'https?://(?:www\.)?bilibili\.com/cheese/play/ep(?P<id>\d+)'
+ _TESTS = [{
+ 'url': 'https://www.bilibili.com/cheese/play/ep229832',
+ 'info_dict': {
+ 'id': '229832',
+ 'ext': 'mp4',
+ 'title': '1 - 课程先导片',
+ 'alt_title': '视频课 · 3分41秒',
+ 'uploader': '马督工',
+ 'uploader_id': '316568752',
+ 'episode': '课程先导片',
+ 'episode_id': '229832',
+ 'episode_number': 1,
+ 'duration': 221,
+ 'timestamp': 1695549606,
+ 'upload_date': '20230924',
+ 'thumbnail': r're:^https?://.*\.(jpg|jpeg|png)$',
+ 'view_count': int,
+ }
+ }]
+
+ def _real_extract(self, url):
+ ep_id = self._match_id(url)
+ return self._extract_episode(self._download_season_info('ep_id', ep_id), ep_id)
+
+
+class BilibiliCheeseSeasonIE(BilibiliCheeseBaseIE):
+ _VALID_URL = r'https?://(?:www\.)?bilibili\.com/cheese/play/ss(?P<id>\d+)'
+ _TESTS = [{
+ 'url': 'https://www.bilibili.com/cheese/play/ss5918',
+ 'info_dict': {
+ 'id': '5918',
+ 'title': '【限时五折】新闻系学不到:马督工教你做自媒体',
+ 'description': '帮普通人建立世界模型,降低人与人的沟通门槛',
+ },
+ 'playlist': [{
+ 'info_dict': {
+ 'id': '229832',
+ 'ext': 'mp4',
+ 'title': '1 - 课程先导片',
+ 'alt_title': '视频课 · 3分41秒',
+ 'uploader': '马督工',
+ 'uploader_id': '316568752',
+ 'episode': '课程先导片',
+ 'episode_id': '229832',
+ 'episode_number': 1,
+ 'duration': 221,
+ 'timestamp': 1695549606,
+ 'upload_date': '20230924',
+ 'thumbnail': r're:^https?://.*\.(jpg|jpeg|png)$',
+ 'view_count': int,
+ }
+ }],
+ 'params': {'playlist_items': '1'},
+ }, {
+ 'url': 'https://www.bilibili.com/cheese/play/ss5918',
+ 'info_dict': {
+ 'id': '5918',
+ 'title': '【限时五折】新闻系学不到:马督工教你做自媒体',
+ 'description': '帮普通人建立世界模型,降低人与人的沟通门槛',
+ },
+ 'playlist_mincount': 5,
+ 'skip': 'paid video in list',
+ }]
+
+ def _get_cheese_entries(self, season_info):
+ for ep_id in traverse_obj(season_info, ('episodes', lambda _, v: v['episode_can_view'], 'id')):
+ yield self._extract_episode(season_info, ep_id)
+
+ def _real_extract(self, url):
+ season_id = self._match_id(url)
+ season_info = self._download_season_info('season_id', season_id)
+
+ return self.playlist_result(
+ self._get_cheese_entries(season_info), season_id,
+ **traverse_obj(season_info, {
+ 'title': ('title', {str}),
+ 'description': ('subtitle', {str}),
+ }))
class BilibiliSpaceBaseIE(InfoExtractor):
response = self._download_json('https://api.bilibili.com/x/space/wbi/arc/search',
playlist_id, note=f'Downloading page {page_idx}', query=query)
except ExtractorError as e:
- if isinstance(e.cause, urllib.error.HTTPError) and e.cause.code == 412:
+ if isinstance(e.cause, HTTPError) and e.cause.status == 412:
raise ExtractorError(
'Request is blocked by server (412), please add cookies, wait and try later.', expected=True)
raise
return self.playlist_result(paged_list, playlist_id)
-class BilibiliSpacePlaylistIE(BilibiliSpaceBaseIE):
- _VALID_URL = r'https?://space.bilibili\.com/(?P<mid>\d+)/channel/collectiondetail\?sid=(?P<sid>\d+)'
+class BilibiliSpaceListBaseIE(BilibiliSpaceBaseIE):
+ def _get_entries(self, page_data, bvid_keys, ending_key='bvid'):
+ for bvid in traverse_obj(page_data, (*variadic(bvid_keys, (str, bytes, dict, set)), ..., ending_key, {str})):
+ yield self.url_result(f'https://www.bilibili.com/video/{bvid}', BiliBiliIE, bvid)
+
+ def _get_uploader(self, uid, playlist_id):
+ webpage = self._download_webpage(f'https://space.bilibili.com/{uid}', playlist_id, fatal=False)
+ return self._search_regex(r'(?s)<title\b[^>]*>([^<]+)的个人空间-', webpage, 'uploader', fatal=False)
+
+ def _extract_playlist(self, fetch_page, get_metadata, get_entries):
+ metadata, page_list = super()._extract_playlist(fetch_page, get_metadata, get_entries)
+ metadata.pop('page_count', None)
+ metadata.pop('page_size', None)
+ return metadata, page_list
+
+
+class BilibiliCollectionListIE(BilibiliSpaceListBaseIE):
+ _VALID_URL = r'https?://space\.bilibili\.com/(?P<mid>\d+)/channel/collectiondetail/?\?sid=(?P<sid>\d+)'
_TESTS = [{
'url': 'https://space.bilibili.com/2142762/channel/collectiondetail?sid=57445',
'info_dict': {
'id': '2142762_57445',
- 'title': '《底特律 变人》'
+ 'title': '【完结】《底特律 变人》全结局流程解说',
+ 'description': '',
+ 'uploader': '老戴在此',
+ 'uploader_id': '2142762',
+ 'timestamp': int,
+ 'upload_date': str,
+ 'thumbnail': 'https://archive.biliimg.com/bfs/archive/e0e543ae35ad3df863ea7dea526bc32e70f4c091.jpg',
},
'playlist_mincount': 31,
}]
return {
'page_count': math.ceil(entry_count / page_size),
'page_size': page_size,
- 'title': traverse_obj(page_data, ('meta', 'name'))
+ 'uploader': self._get_uploader(mid, playlist_id),
+ **traverse_obj(page_data, {
+ 'title': ('meta', 'name', {str}),
+ 'description': ('meta', 'description', {str}),
+ 'uploader_id': ('meta', 'mid', {str_or_none}),
+ 'timestamp': ('meta', 'ptime', {int_or_none}),
+ 'thumbnail': ('meta', 'cover', {url_or_none}),
+ })
+ }
+
+ def get_entries(page_data):
+ return self._get_entries(page_data, 'archives')
+
+ metadata, paged_list = self._extract_playlist(fetch_page, get_metadata, get_entries)
+ return self.playlist_result(paged_list, playlist_id, **metadata)
+
+
+class BilibiliSeriesListIE(BilibiliSpaceListBaseIE):
+ _VALID_URL = r'https?://space\.bilibili\.com/(?P<mid>\d+)/channel/seriesdetail/?\?\bsid=(?P<sid>\d+)'
+ _TESTS = [{
+ 'url': 'https://space.bilibili.com/1958703906/channel/seriesdetail?sid=547718&ctype=0',
+ 'info_dict': {
+ 'id': '1958703906_547718',
+ 'title': '直播回放',
+ 'description': '直播回放',
+ 'uploader': '靡烟miya',
+ 'uploader_id': '1958703906',
+ 'timestamp': 1637985853,
+ 'upload_date': '20211127',
+ 'modified_timestamp': int,
+ 'modified_date': str,
+ },
+ 'playlist_mincount': 513,
+ }]
+
+ def _real_extract(self, url):
+ mid, sid = self._match_valid_url(url).group('mid', 'sid')
+ playlist_id = f'{mid}_{sid}'
+ playlist_meta = traverse_obj(self._download_json(
+ f'https://api.bilibili.com/x/series/series?series_id={sid}', playlist_id, fatal=False
+ ), {
+ 'title': ('data', 'meta', 'name', {str}),
+ 'description': ('data', 'meta', 'description', {str}),
+ 'uploader_id': ('data', 'meta', 'mid', {str_or_none}),
+ 'timestamp': ('data', 'meta', 'ctime', {int_or_none}),
+ 'modified_timestamp': ('data', 'meta', 'mtime', {int_or_none}),
+ })
+
+ def fetch_page(page_idx):
+ return self._download_json(
+ 'https://api.bilibili.com/x/series/archives',
+ playlist_id, note=f'Downloading page {page_idx}',
+ query={'mid': mid, 'series_id': sid, 'pn': page_idx + 1, 'ps': 30})['data']
+
+ def get_metadata(page_data):
+ page_size = page_data['page']['size']
+ entry_count = page_data['page']['total']
+ return {
+ 'page_count': math.ceil(entry_count / page_size),
+ 'page_size': page_size,
+ 'uploader': self._get_uploader(mid, playlist_id),
+ **playlist_meta
}
def get_entries(page_data):
- for entry in page_data.get('archives', []):
- yield self.url_result(f'https://www.bilibili.com/video/{entry["bvid"]}',
- BiliBiliIE, entry['bvid'])
+ return self._get_entries(page_data, 'archives')
metadata, paged_list = self._extract_playlist(fetch_page, get_metadata, get_entries)
- return self.playlist_result(paged_list, playlist_id, metadata['title'])
+ return self.playlist_result(paged_list, playlist_id, **metadata)
+
+
+class BilibiliFavoritesListIE(BilibiliSpaceListBaseIE):
+ _VALID_URL = r'https?://(?:space\.bilibili\.com/\d+/favlist/?\?fid=|(?:www\.)?bilibili\.com/medialist/detail/ml)(?P<id>\d+)'
+ _TESTS = [{
+ 'url': 'https://space.bilibili.com/84912/favlist?fid=1103407912&ftype=create',
+ 'info_dict': {
+ 'id': '1103407912',
+ 'title': '【V2】(旧)',
+ 'description': '',
+ 'uploader': '晓月春日',
+ 'uploader_id': '84912',
+ 'timestamp': 1604905176,
+ 'upload_date': '20201109',
+ 'modified_timestamp': int,
+ 'modified_date': str,
+ 'thumbnail': r"re:http://i\d\.hdslb\.com/bfs/archive/14b83c62aa8871b79083df1e9ab4fbc699ad16fe\.jpg",
+ 'view_count': int,
+ 'like_count': int,
+ },
+ 'playlist_mincount': 22,
+ }, {
+ 'url': 'https://www.bilibili.com/medialist/detail/ml1103407912',
+ 'only_matching': True,
+ }]
+
+ def _real_extract(self, url):
+ fid = self._match_id(url)
+
+ list_info = self._download_json(
+ f'https://api.bilibili.com/x/v3/fav/resource/list?media_id={fid}&pn=1&ps=20',
+ fid, note='Downloading favlist metadata')
+ if list_info['code'] == -403:
+ self.raise_login_required(msg='This is a private favorites list. You need to log in as its owner')
+
+ entries = self._get_entries(self._download_json(
+ f'https://api.bilibili.com/x/v3/fav/resource/ids?media_id={fid}',
+ fid, note='Download favlist entries'), 'data')
+
+ return self.playlist_result(entries, fid, **traverse_obj(list_info, ('data', 'info', {
+ 'title': ('title', {str}),
+ 'description': ('intro', {str}),
+ 'uploader': ('upper', 'name', {str}),
+ 'uploader_id': ('upper', 'mid', {str_or_none}),
+ 'timestamp': ('ctime', {int_or_none}),
+ 'modified_timestamp': ('mtime', {int_or_none}),
+ 'thumbnail': ('cover', {url_or_none}),
+ 'view_count': ('cnt_info', 'play', {int_or_none}),
+ 'like_count': ('cnt_info', 'thumb_up', {int_or_none}),
+ })))
+
+
+class BilibiliWatchlaterIE(BilibiliSpaceListBaseIE):
+ _VALID_URL = r'https?://(?:www\.)?bilibili\.com/watchlater/?(?:[?#]|$)'
+ _TESTS = [{
+ 'url': 'https://www.bilibili.com/watchlater/#/list',
+ 'info_dict': {'id': 'watchlater'},
+ 'playlist_mincount': 0,
+ 'skip': 'login required',
+ }]
+
+ def _real_extract(self, url):
+ list_id = getattr(self._get_cookies(url).get('DedeUserID'), 'value', 'watchlater')
+ watchlater_info = self._download_json(
+ 'https://api.bilibili.com/x/v2/history/toview/web?jsonp=jsonp', list_id)
+ if watchlater_info['code'] == -101:
+ self.raise_login_required(msg='You need to login to access your watchlater list')
+ entries = self._get_entries(watchlater_info, ('data', 'list'))
+ return self.playlist_result(entries, id=list_id, title='稍后再看')
+
+
+class BilibiliPlaylistIE(BilibiliSpaceListBaseIE):
+ _VALID_URL = r'https?://(?:www\.)?bilibili\.com/(?:medialist/play|list)/(?P<id>\w+)'
+ _TESTS = [{
+ 'url': 'https://www.bilibili.com/list/1958703906?sid=547718',
+ 'info_dict': {
+ 'id': '5_547718',
+ 'title': '直播回放',
+ 'uploader': '靡烟miya',
+ 'uploader_id': '1958703906',
+ 'timestamp': 1637985853,
+ 'upload_date': '20211127',
+ },
+ 'playlist_mincount': 513,
+ }, {
+ 'url': 'https://www.bilibili.com/medialist/play/1958703906?business=space_series&business_id=547718&desc=1',
+ 'info_dict': {
+ 'id': '5_547718',
+ },
+ 'playlist_mincount': 513,
+ 'skip': 'redirect url',
+ }, {
+ 'url': 'https://www.bilibili.com/list/ml1103407912',
+ 'info_dict': {
+ 'id': '3_1103407912',
+ 'title': '【V2】(旧)',
+ 'uploader': '晓月春日',
+ 'uploader_id': '84912',
+ 'timestamp': 1604905176,
+ 'upload_date': '20201109',
+ 'thumbnail': r"re:http://i\d\.hdslb\.com/bfs/archive/14b83c62aa8871b79083df1e9ab4fbc699ad16fe\.jpg",
+ },
+ 'playlist_mincount': 22,
+ }, {
+ 'url': 'https://www.bilibili.com/medialist/play/ml1103407912',
+ 'info_dict': {
+ 'id': '3_1103407912',
+ },
+ 'playlist_mincount': 22,
+ 'skip': 'redirect url',
+ }, {
+ 'url': 'https://www.bilibili.com/list/watchlater',
+ 'info_dict': {'id': 'watchlater'},
+ 'playlist_mincount': 0,
+ 'skip': 'login required',
+ }, {
+ 'url': 'https://www.bilibili.com/medialist/play/watchlater',
+ 'info_dict': {'id': 'watchlater'},
+ 'playlist_mincount': 0,
+ 'skip': 'login required',
+ }]
+
+ def _extract_medialist(self, query, list_id):
+ for page_num in itertools.count(1):
+ page_data = self._download_json(
+ 'https://api.bilibili.com/x/v2/medialist/resource/list',
+ list_id, query=query, note=f'getting playlist {query["biz_id"]} page {page_num}'
+ )['data']
+ yield from self._get_entries(page_data, 'media_list', ending_key='bv_id')
+ query['oid'] = traverse_obj(page_data, ('media_list', -1, 'id'))
+ if not page_data.get('has_more', False):
+ break
+
+ def _real_extract(self, url):
+ list_id = self._match_id(url)
+ webpage = self._download_webpage(url, list_id)
+ initial_state = self._search_json(r'window\.__INITIAL_STATE__\s*=', webpage, 'initial state', list_id)
+ if traverse_obj(initial_state, ('error', 'code', {int_or_none})) != 200:
+ error_code = traverse_obj(initial_state, ('error', 'trueCode', {int_or_none}))
+ error_message = traverse_obj(initial_state, ('error', 'message', {str_or_none}))
+ if error_code == -400 and list_id == 'watchlater':
+ self.raise_login_required('You need to login to access your watchlater playlist')
+ elif error_code == -403:
+ self.raise_login_required('This is a private playlist. You need to login as its owner')
+ elif error_code == 11010:
+ raise ExtractorError('Playlist is no longer available', expected=True)
+ raise ExtractorError(f'Could not access playlist: {error_code} {error_message}')
+
+ query = {
+ 'ps': 20,
+ 'with_current': False,
+ **traverse_obj(initial_state, {
+ 'type': ('playlist', 'type', {int_or_none}),
+ 'biz_id': ('playlist', 'id', {int_or_none}),
+ 'tid': ('tid', {int_or_none}),
+ 'sort_field': ('sortFiled', {int_or_none}),
+ 'desc': ('desc', {bool_or_none}, {str_or_none}, {str.lower}),
+ })
+ }
+ metadata = {
+ 'id': f'{query["type"]}_{query["biz_id"]}',
+ **traverse_obj(initial_state, ('mediaListInfo', {
+ 'title': ('title', {str}),
+ 'uploader': ('upper', 'name', {str}),
+ 'uploader_id': ('upper', 'mid', {str_or_none}),
+ 'timestamp': ('ctime', {int_or_none}),
+ 'thumbnail': ('cover', {url_or_none}),
+ })),
+ }
+ return self.playlist_result(self._extract_medialist(query, list_id), **metadata)
class BilibiliCategoryIE(InfoExtractor):
IE_NAME = 'Bilibili category extractor'
_MAX_RESULTS = 1000000
- _VALID_URL = r'https?://www\.bilibili\.com/v/[a-zA-Z]+\/[a-zA-Z]+'
+ _VALID_URL = r'https?://(?:www\.)?bilibili\.com/v/[a-zA-Z]+\/[a-zA-Z]+'
_TESTS = [{
'url': 'https://www.bilibili.com/v/kichiku/mad',
'info_dict': {
class BiliLiveIE(InfoExtractor):
- _VALID_URL = r'https?://live.bilibili.com/(?:blanc/)?(?P<id>\d+)'
+ _VALID_URL = r'https?://live\.bilibili\.com/(?:blanc/)?(?P<id>\d+)'
_TESTS = [{
'url': 'https://live.bilibili.com/196',