vk.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859
  1. import collections
  2. import hashlib
  3. import re
  4. from .common import InfoExtractor
  5. from .dailymotion import DailymotionIE
  6. from .odnoklassniki import OdnoklassnikiIE
  7. from .pladform import PladformIE
  8. from .sibnet import SibnetEmbedIE
  9. from .vimeo import VimeoIE
  10. from .youtube import YoutubeIE
  11. from ..utils import (
  12. ExtractorError,
  13. UserNotLive,
  14. clean_html,
  15. get_element_by_class,
  16. get_element_html_by_id,
  17. int_or_none,
  18. join_nonempty,
  19. parse_resolution,
  20. str_or_none,
  21. str_to_int,
  22. traverse_obj,
  23. try_call,
  24. unescapeHTML,
  25. unified_timestamp,
  26. update_url_query,
  27. url_or_none,
  28. urlencode_postdata,
  29. urljoin,
  30. )
  31. class VKBaseIE(InfoExtractor):
  32. _NETRC_MACHINE = 'vk'
  33. def _download_webpage_handle(self, url_or_request, video_id, *args, fatal=True, **kwargs):
  34. response = super()._download_webpage_handle(url_or_request, video_id, *args, fatal=fatal, **kwargs)
  35. challenge_url, cookie = response[1].url if response else '', None
  36. if challenge_url.startswith('https://vk.com/429.html?'):
  37. cookie = self._get_cookies(challenge_url).get('hash429')
  38. if not cookie:
  39. return response
  40. hash429 = hashlib.md5(cookie.value.encode('ascii')).hexdigest()
  41. self._request_webpage(
  42. update_url_query(challenge_url, {'key': hash429}), video_id, fatal=fatal,
  43. note='Resolving WAF challenge', errnote='Failed to bypass WAF challenge')
  44. return super()._download_webpage_handle(url_or_request, video_id, *args, fatal=True, **kwargs)
  45. def _perform_login(self, username, password):
  46. login_page, url_handle = self._download_webpage_handle(
  47. 'https://vk.com', None, 'Downloading login page')
  48. login_form = self._hidden_inputs(login_page)
  49. login_form.update({
  50. 'email': username.encode('cp1251'),
  51. 'pass': password.encode('cp1251'),
  52. })
  53. # vk serves two same remixlhk cookies in Set-Cookie header and expects
  54. # first one to be actually set
  55. self._apply_first_set_cookie_header(url_handle, 'remixlhk')
  56. login_page = self._download_webpage(
  57. 'https://vk.com/login', None,
  58. note='Logging in',
  59. data=urlencode_postdata(login_form))
  60. if re.search(r'onLoginFailed', login_page):
  61. raise ExtractorError(
  62. 'Unable to login, incorrect username and/or password', expected=True)
  63. def _download_payload(self, path, video_id, data, fatal=True):
  64. endpoint = f'https://vk.com/{path}.php'
  65. data['al'] = 1
  66. code, payload = self._download_json(
  67. endpoint, video_id, data=urlencode_postdata(data), fatal=fatal,
  68. headers={
  69. 'Referer': endpoint,
  70. 'X-Requested-With': 'XMLHttpRequest',
  71. })['payload']
  72. if code == '3':
  73. self.raise_login_required()
  74. elif code == '8':
  75. raise ExtractorError(clean_html(payload[0][1:-1]), expected=True)
  76. return payload
  77. class VKIE(VKBaseIE):
  78. IE_NAME = 'vk'
  79. IE_DESC = 'VK'
  80. _EMBED_REGEX = [r'<iframe[^>]+?src=(["\'])(?P<url>https?://vk\.com/video_ext\.php.+?)\1']
  81. _VALID_URL = r'''(?x)
  82. https?://
  83. (?:
  84. (?:
  85. (?:(?:m|new)\.)?vk\.com/video_|
  86. (?:www\.)?daxab\.com/
  87. )
  88. ext\.php\?(?P<embed_query>.*?\boid=(?P<oid>-?\d+).*?\bid=(?P<id>\d+).*)|
  89. (?:
  90. (?:(?:m|new)\.)?vk\.com/(?:.+?\?.*?z=)?(?:video|clip)|
  91. (?:www\.)?daxab\.com/embed/
  92. )
  93. (?P<videoid>-?\d+_\d+)(?:.*\blist=(?P<list_id>([\da-f]+)|(ln-[\da-zA-Z]+)))?
  94. )
  95. '''
  96. _TESTS = [
  97. {
  98. 'url': 'http://vk.com/videos-77521?z=video-77521_162222515%2Fclub77521',
  99. 'info_dict': {
  100. 'id': '-77521_162222515',
  101. 'ext': 'mp4',
  102. 'title': 'ProtivoGunz - Хуёвая песня',
  103. 'uploader': 're:(?:Noize MC|Alexander Ilyashenko).*',
  104. 'uploader_id': '39545378',
  105. 'duration': 195,
  106. 'timestamp': 1329049880,
  107. 'upload_date': '20120212',
  108. 'comment_count': int,
  109. 'like_count': int,
  110. 'thumbnail': r're:https?://.+(?:\.jpg|getVideoPreview.*)$',
  111. },
  112. 'params': {'skip_download': 'm3u8'},
  113. },
  114. {
  115. 'url': 'http://vk.com/video205387401_165548505',
  116. 'info_dict': {
  117. 'id': '205387401_165548505',
  118. 'ext': 'mp4',
  119. 'title': 'No name',
  120. 'uploader': 'Tom Cruise',
  121. 'uploader_id': '205387401',
  122. 'duration': 9,
  123. 'timestamp': 1374364108,
  124. 'upload_date': '20130720',
  125. 'comment_count': int,
  126. 'like_count': int,
  127. 'thumbnail': r're:https?://.+(?:\.jpg|getVideoPreview.*)$',
  128. },
  129. },
  130. {
  131. 'note': 'Embedded video',
  132. 'url': 'https://vk.com/video_ext.php?oid=-77521&id=162222515&hash=87b046504ccd8bfa',
  133. 'info_dict': {
  134. 'id': '-77521_162222515',
  135. 'ext': 'mp4',
  136. 'uploader': 're:(?:Noize MC|Alexander Ilyashenko).*',
  137. 'title': 'ProtivoGunz - Хуёвая песня',
  138. 'duration': 195,
  139. 'upload_date': '20120212',
  140. 'timestamp': 1329049880,
  141. 'uploader_id': '39545378',
  142. 'thumbnail': r're:https?://.+(?:\.jpg|getVideoPreview.*)$',
  143. },
  144. 'params': {'skip_download': 'm3u8'},
  145. },
  146. {
  147. 'url': 'https://vk.com/video-93049196_456239755?list=ln-cBjJ7S4jYYx3ADnmDT',
  148. 'info_dict': {
  149. 'id': '-93049196_456239755',
  150. 'ext': 'mp4',
  151. 'title': '8 серия (озвучка)',
  152. 'duration': 8383,
  153. 'comment_count': int,
  154. 'uploader': 'Dizi2021',
  155. 'like_count': int,
  156. 'timestamp': 1640162189,
  157. 'upload_date': '20211222',
  158. 'uploader_id': '-93049196',
  159. 'thumbnail': r're:https?://.+(?:\.jpg|getVideoPreview.*)$',
  160. },
  161. },
  162. {
  163. 'note': 'youtube embed',
  164. 'url': 'https://vk.com/video276849682_170681728',
  165. 'info_dict': {
  166. 'id': 'V3K4mi0SYkc',
  167. 'ext': 'mp4',
  168. 'title': "DSWD Awards 'Children's Joy Foundation, Inc.' Certificate of Registration and License to Operate",
  169. 'description': 'md5:bf9c26cfa4acdfb146362682edd3827a',
  170. 'duration': 178,
  171. 'upload_date': '20130117',
  172. 'uploader': "Children's Joy Foundation Inc.",
  173. 'uploader_id': 'thecjf',
  174. 'view_count': int,
  175. 'channel_id': 'UCgzCNQ11TmR9V97ECnhi3gw',
  176. 'availability': 'public',
  177. 'like_count': int,
  178. 'live_status': 'not_live',
  179. 'playable_in_embed': True,
  180. 'channel': 'Children\'s Joy Foundation Inc.',
  181. 'uploader_url': 'http://www.youtube.com/user/thecjf',
  182. 'thumbnail': r're:https?://.+\.jpg$',
  183. 'tags': 'count:27',
  184. 'start_time': 0.0,
  185. 'categories': ['Nonprofits & Activism'],
  186. 'channel_url': 'https://www.youtube.com/channel/UCgzCNQ11TmR9V97ECnhi3gw',
  187. 'channel_follower_count': int,
  188. 'age_limit': 0,
  189. },
  190. },
  191. {
  192. 'note': 'dailymotion embed',
  193. 'url': 'https://vk.com/video-95168827_456239103?list=cca524a0f0d5557e16',
  194. 'info_dict': {
  195. 'id': 'x8gfli0',
  196. 'ext': 'mp4',
  197. 'title': 'md5:45410f60ccd4b2760da98cb5fc777d70',
  198. 'description': 'md5:2e71c5c9413735cfa06cf1a166f16c84',
  199. 'uploader': 'Movies and cinema.',
  200. 'upload_date': '20221218',
  201. 'uploader_id': 'x1jdavv',
  202. 'timestamp': 1671387617,
  203. 'age_limit': 0,
  204. 'duration': 2918,
  205. 'like_count': int,
  206. 'view_count': int,
  207. 'thumbnail': r're:https?://.+x1080$',
  208. 'tags': list,
  209. },
  210. },
  211. {
  212. 'url': 'https://vk.com/clips-74006511?z=clip-74006511_456247211',
  213. 'info_dict': {
  214. 'id': '-74006511_456247211',
  215. 'ext': 'mp4',
  216. 'comment_count': int,
  217. 'duration': 9,
  218. 'like_count': int,
  219. 'thumbnail': r're:https?://.+(?:\.jpg|getVideoPreview.*)$',
  220. 'timestamp': 1664995597,
  221. 'title': 'Clip by @madempress',
  222. 'upload_date': '20221005',
  223. 'uploader': 'Шальная императрица',
  224. 'uploader_id': '-74006511',
  225. },
  226. },
  227. {
  228. # video key is extra_data not url\d+
  229. 'url': 'http://vk.com/video-110305615_171782105',
  230. 'md5': 'e13fcda136f99764872e739d13fac1d1',
  231. 'info_dict': {
  232. 'id': '-110305615_171782105',
  233. 'ext': 'mp4',
  234. 'title': 'S-Dance, репетиции к The way show',
  235. 'uploader': 'THE WAY SHOW | 17 апреля',
  236. 'uploader_id': '-110305615',
  237. 'timestamp': 1454859345,
  238. 'upload_date': '20160207',
  239. },
  240. 'skip': 'Removed',
  241. },
  242. {
  243. 'note': 'finished live stream, postlive_mp4',
  244. 'url': 'https://vk.com/videos-387766?z=video-387766_456242764%2Fpl_-387766_-2',
  245. 'info_dict': {
  246. 'id': '-387766_456242764',
  247. 'ext': 'mp4',
  248. 'title': 'ИгроМир 2016 День 1 — Игромания Утром',
  249. 'uploader': 'Игромания',
  250. 'duration': 5239,
  251. 'upload_date': '20160929',
  252. 'uploader_id': '-387766',
  253. 'timestamp': 1475137527,
  254. 'thumbnail': r're:https?://.+\.jpg$',
  255. 'comment_count': int,
  256. 'like_count': int,
  257. },
  258. 'params': {
  259. 'skip_download': True,
  260. },
  261. },
  262. {
  263. # live stream, hls and rtmp links, most likely already finished live
  264. # stream by the time you are reading this comment
  265. 'url': 'https://vk.com/video-140332_456239111',
  266. 'only_matching': True,
  267. },
  268. {
  269. # removed video, just testing that we match the pattern
  270. 'url': 'http://vk.com/feed?z=video-43215063_166094326%2Fbb50cacd3177146d7a',
  271. 'only_matching': True,
  272. },
  273. {
  274. # age restricted video, requires vk account credentials
  275. 'url': 'https://vk.com/video205387401_164765225',
  276. 'only_matching': True,
  277. },
  278. {
  279. # pladform embed
  280. 'url': 'https://vk.com/video-76116461_171554880',
  281. 'only_matching': True,
  282. },
  283. {
  284. 'url': 'http://new.vk.com/video205387401_165548505',
  285. 'only_matching': True,
  286. },
  287. {
  288. # This video is no longer available, because its author has been blocked.
  289. 'url': 'https://vk.com/video-10639516_456240611',
  290. 'only_matching': True,
  291. },
  292. {
  293. # The video is not available in your region.
  294. 'url': 'https://vk.com/video-51812607_171445436',
  295. 'only_matching': True,
  296. },
  297. {
  298. 'url': 'https://vk.com/clip30014565_456240946',
  299. 'only_matching': True,
  300. }]
  301. def _real_extract(self, url):
  302. mobj = self._match_valid_url(url)
  303. video_id = mobj.group('videoid')
  304. mv_data = {}
  305. if video_id:
  306. data = {
  307. 'act': 'show',
  308. 'video': video_id,
  309. }
  310. # Some videos (removed?) can only be downloaded with list id specified
  311. list_id = mobj.group('list_id')
  312. if list_id:
  313. data['list'] = list_id
  314. payload = self._download_payload('al_video', video_id, data)
  315. info_page = payload[1]
  316. opts = payload[-1]
  317. mv_data = opts.get('mvData') or {}
  318. player = opts.get('player') or {}
  319. else:
  320. video_id = '{}_{}'.format(mobj.group('oid'), mobj.group('id'))
  321. info_page = self._download_webpage(
  322. 'http://vk.com/video_ext.php?' + mobj.group('embed_query'), video_id)
  323. error_message = self._html_search_regex(
  324. [r'(?s)<!><div[^>]+class="video_layer_message"[^>]*>(.+?)</div>',
  325. r'(?s)<div[^>]+id="video_ext_msg"[^>]*>(.+?)</div>'],
  326. info_page, 'error message', default=None)
  327. if error_message:
  328. raise ExtractorError(error_message, expected=True)
  329. if re.search(r'<!>/login\.php\?.*\bact=security_check', info_page):
  330. raise ExtractorError(
  331. 'You are trying to log in from an unusual location. You should confirm ownership at vk.com to log in with this IP.',
  332. expected=True)
  333. ERROR_COPYRIGHT = 'Video %s has been removed from public access due to rightholder complaint.'
  334. ERRORS = {
  335. r'>Видеозапись .*? была изъята из публичного доступа в связи с обращением правообладателя.<':
  336. ERROR_COPYRIGHT,
  337. r'>The video .*? was removed from public access by request of the copyright holder.<':
  338. ERROR_COPYRIGHT,
  339. r'<!>Please log in or <':
  340. 'Video %s is only available for registered users, '
  341. 'use --username and --password options to provide account credentials.',
  342. r'<!>Unknown error':
  343. 'Video %s does not exist.',
  344. r'<!>Видео временно недоступно':
  345. 'Video %s is temporarily unavailable.',
  346. r'<!>Access denied':
  347. 'Access denied to video %s.',
  348. r'<!>Видеозапись недоступна, так как её автор был заблокирован.':
  349. 'Video %s is no longer available, because its author has been blocked.',
  350. r'<!>This video is no longer available, because its author has been blocked.':
  351. 'Video %s is no longer available, because its author has been blocked.',
  352. r'<!>This video is no longer available, because it has been deleted.':
  353. 'Video %s is no longer available, because it has been deleted.',
  354. r'<!>The video .+? is not available in your region.':
  355. 'Video %s is not available in your region.',
  356. }
  357. for error_re, error_msg in ERRORS.items():
  358. if re.search(error_re, info_page):
  359. raise ExtractorError(error_msg % video_id, expected=True)
  360. player = self._parse_json(self._search_regex(
  361. r'var\s+playerParams\s*=\s*({.+?})\s*;\s*\n',
  362. info_page, 'player params'), video_id)
  363. youtube_url = YoutubeIE._extract_url(info_page)
  364. if youtube_url:
  365. return self.url_result(youtube_url, YoutubeIE.ie_key())
  366. vimeo_url = VimeoIE._extract_url(url, info_page)
  367. if vimeo_url is not None:
  368. return self.url_result(vimeo_url, VimeoIE.ie_key())
  369. pladform_url = PladformIE._extract_url(info_page)
  370. if pladform_url:
  371. return self.url_result(pladform_url, PladformIE.ie_key())
  372. m_rutube = re.search(
  373. r'\ssrc="((?:https?:)?//rutube\.ru\\?/(?:video|play)\\?/embed(?:.*?))\\?"', info_page)
  374. if m_rutube is not None:
  375. rutube_url = self._proto_relative_url(
  376. m_rutube.group(1).replace('\\', ''))
  377. return self.url_result(rutube_url)
  378. dailymotion_url = next(DailymotionIE._extract_embed_urls(url, info_page), None)
  379. if dailymotion_url:
  380. return self.url_result(dailymotion_url, DailymotionIE.ie_key())
  381. odnoklassniki_url = OdnoklassnikiIE._extract_url(info_page)
  382. if odnoklassniki_url:
  383. return self.url_result(odnoklassniki_url, OdnoklassnikiIE.ie_key())
  384. sibnet_url = next(SibnetEmbedIE._extract_embed_urls(url, info_page), None)
  385. if sibnet_url:
  386. return self.url_result(sibnet_url)
  387. m_opts = re.search(r'(?s)var\s+opts\s*=\s*({.+?});', info_page)
  388. if m_opts:
  389. m_opts_url = re.search(r"url\s*:\s*'((?!/\b)[^']+)", m_opts.group(1))
  390. if m_opts_url:
  391. opts_url = m_opts_url.group(1)
  392. if opts_url.startswith('//'):
  393. opts_url = 'http:' + opts_url
  394. return self.url_result(opts_url)
  395. data = player['params'][0]
  396. title = unescapeHTML(data['md_title'])
  397. # 2 = live
  398. # 3 = post live (finished live)
  399. is_live = data.get('live') == 2
  400. timestamp = unified_timestamp(self._html_search_regex(
  401. r'class=["\']mv_info_date[^>]+>([^<]+)(?:<|from)', info_page,
  402. 'upload date', default=None)) or int_or_none(data.get('date'))
  403. view_count = str_to_int(self._search_regex(
  404. r'class=["\']mv_views_count[^>]+>\s*([\d,.]+)',
  405. info_page, 'view count', default=None))
  406. formats = []
  407. subtitles = {}
  408. for format_id, format_url in data.items():
  409. format_url = url_or_none(format_url)
  410. if not format_url or not format_url.startswith(('http', '//', 'rtmp')):
  411. continue
  412. if (format_id.startswith(('url', 'cache'))
  413. or format_id in ('extra_data', 'live_mp4', 'postlive_mp4')):
  414. height = int_or_none(self._search_regex(
  415. r'^(?:url|cache)(\d+)', format_id, 'height', default=None))
  416. formats.append({
  417. 'format_id': format_id,
  418. 'url': format_url,
  419. 'ext': 'mp4',
  420. 'source_preference': 1,
  421. 'height': height,
  422. })
  423. elif format_id.startswith('hls') and format_id != 'hls_live_playback':
  424. fmts, subs = self._extract_m3u8_formats_and_subtitles(
  425. format_url, video_id, 'mp4', 'm3u8_native',
  426. m3u8_id=format_id, fatal=False, live=is_live)
  427. formats.extend(fmts)
  428. self._merge_subtitles(subs, target=subtitles)
  429. elif format_id.startswith('dash') and format_id not in ('dash_live_playback', 'dash_uni'):
  430. fmts, subs = self._extract_mpd_formats_and_subtitles(
  431. format_url, video_id, mpd_id=format_id, fatal=False)
  432. formats.extend(fmts)
  433. self._merge_subtitles(subs, target=subtitles)
  434. elif format_id == 'rtmp':
  435. formats.append({
  436. 'format_id': format_id,
  437. 'url': format_url,
  438. 'ext': 'flv',
  439. })
  440. for sub in data.get('subs') or {}:
  441. subtitles.setdefault(sub.get('lang', 'en'), []).append({
  442. 'ext': sub.get('title', '.srt').split('.')[-1],
  443. 'url': url_or_none(sub.get('url')),
  444. })
  445. return {
  446. 'id': video_id,
  447. 'formats': formats,
  448. 'title': title,
  449. 'thumbnail': data.get('jpg'),
  450. 'uploader': data.get('md_author'),
  451. 'uploader_id': str_or_none(data.get('author_id') or mv_data.get('authorId')),
  452. 'duration': int_or_none(data.get('duration') or mv_data.get('duration')),
  453. 'timestamp': timestamp,
  454. 'view_count': view_count,
  455. 'like_count': int_or_none(mv_data.get('likes')),
  456. 'comment_count': int_or_none(mv_data.get('commcount')),
  457. 'is_live': is_live,
  458. 'subtitles': subtitles,
  459. '_format_sort_fields': ('res', 'source'),
  460. }
  461. class VKUserVideosIE(VKBaseIE):
  462. IE_NAME = 'vk:uservideos'
  463. IE_DESC = "VK - User's Videos"
  464. _VALID_URL = r'https?://(?:(?:m|new)\.)?vk\.com/video/(?:playlist/)?(?P<id>[^?$#/&]+)(?!\?.*\bz=video)(?:[/?#&](?:.*?\bsection=(?P<section>\w+))?|$)'
  465. _TEMPLATE_URL = 'https://vk.com/videos'
  466. _TESTS = [{
  467. 'url': 'https://vk.com/video/@mobidevices',
  468. 'info_dict': {
  469. 'id': '-17892518_all',
  470. },
  471. 'playlist_mincount': 1355,
  472. }, {
  473. 'url': 'https://vk.com/video/@mobidevices?section=uploaded',
  474. 'info_dict': {
  475. 'id': '-17892518_uploaded',
  476. },
  477. 'playlist_mincount': 182,
  478. }, {
  479. 'url': 'https://vk.com/video/playlist/-174476437_2',
  480. 'info_dict': {
  481. 'id': '-174476437_playlist_2',
  482. 'title': 'Анонсы',
  483. },
  484. 'playlist_mincount': 108,
  485. }]
  486. _VIDEO = collections.namedtuple('Video', ['owner_id', 'id'])
  487. def _entries(self, page_id, section):
  488. video_list_json = self._download_payload('al_video', page_id, {
  489. 'act': 'load_videos_silent',
  490. 'offset': 0,
  491. 'oid': page_id,
  492. 'section': section,
  493. })[0][section]
  494. count = video_list_json['count']
  495. total = video_list_json['total']
  496. video_list = video_list_json['list']
  497. while True:
  498. for video in video_list:
  499. v = self._VIDEO._make(video[:2])
  500. video_id = '%d_%d' % (v.owner_id, v.id)
  501. yield self.url_result(
  502. 'http://vk.com/video' + video_id, VKIE.ie_key(), video_id)
  503. if count >= total:
  504. break
  505. video_list_json = self._download_payload('al_video', page_id, {
  506. 'act': 'load_videos_silent',
  507. 'offset': count,
  508. 'oid': page_id,
  509. 'section': section,
  510. })[0][section]
  511. count += video_list_json['count']
  512. video_list = video_list_json['list']
  513. def _real_extract(self, url):
  514. u_id, section = self._match_valid_url(url).groups()
  515. webpage = self._download_webpage(url, u_id)
  516. if u_id.startswith('@'):
  517. page_id = self._search_regex(r'data-owner-id\s?=\s?"([^"]+)"', webpage, 'page_id')
  518. elif '_' in u_id:
  519. page_id, section = u_id.split('_', 1)
  520. section = f'playlist_{section}'
  521. else:
  522. raise ExtractorError('Invalid URL', expected=True)
  523. if not section:
  524. section = 'all'
  525. playlist_title = clean_html(get_element_by_class('VideoInfoPanel__title', webpage))
  526. return self.playlist_result(self._entries(page_id, section), f'{page_id}_{section}', playlist_title)
  527. class VKWallPostIE(VKBaseIE):
  528. IE_NAME = 'vk:wallpost'
  529. _VALID_URL = r'https?://(?:(?:(?:(?:m|new)\.)?vk\.com/(?:[^?]+\?.*\bw=)?wall(?P<id>-?\d+_\d+)))'
  530. _TESTS = [{
  531. # public page URL, audio playlist
  532. 'url': 'https://vk.com/bs.official?w=wall-23538238_35',
  533. 'info_dict': {
  534. 'id': '-23538238_35',
  535. 'title': 'Black Shadow - Wall post -23538238_35',
  536. 'description': 'md5:190c78f905a53e0de793d83933c6e67f',
  537. },
  538. 'playlist': [{
  539. 'md5': '5ba93864ec5b85f7ce19a9af4af080f6',
  540. 'info_dict': {
  541. 'id': '135220665_111806521',
  542. 'ext': 'm4a',
  543. 'title': 'Black Shadow - Слепое Верование',
  544. 'duration': 370,
  545. 'uploader': 'Black Shadow',
  546. 'artist': 'Black Shadow',
  547. 'track': 'Слепое Верование',
  548. },
  549. }, {
  550. 'md5': '4cc7e804579122b17ea95af7834c9233',
  551. 'info_dict': {
  552. 'id': '135220665_111802303',
  553. 'ext': 'm4a',
  554. 'title': 'Black Shadow - Война - Негасимое Бездны Пламя!',
  555. 'duration': 423,
  556. 'uploader': 'Black Shadow',
  557. 'artist': 'Black Shadow',
  558. 'track': 'Война - Негасимое Бездны Пламя!',
  559. },
  560. }],
  561. 'params': {
  562. 'skip_download': True,
  563. },
  564. }, {
  565. # single YouTube embed with irrelevant reaction videos
  566. 'url': 'https://vk.com/wall-32370614_7173954',
  567. 'info_dict': {
  568. 'id': '-32370614_7173954',
  569. 'title': 'md5:9f93c405bbc00061d34007d78c75e3bc',
  570. 'description': 'md5:953b811f26fa9f21ee5856e2ea8e68fc',
  571. },
  572. 'playlist_count': 1,
  573. }, {
  574. # wall page URL
  575. 'url': 'https://vk.com/wall-23538238_35',
  576. 'only_matching': True,
  577. }, {
  578. # mobile wall page URL
  579. 'url': 'https://m.vk.com/wall-23538238_35',
  580. 'only_matching': True,
  581. }]
  582. _BASE64_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN0PQRSTUVWXYZO123456789+/='
  583. _AUDIO = collections.namedtuple('Audio', ['id', 'owner_id', 'url', 'title', 'performer', 'duration', 'album_id', 'unk', 'author_link', 'lyrics', 'flags', 'context', 'extra', 'hashes', 'cover_url', 'ads'])
  584. def _decode(self, enc):
  585. dec = ''
  586. e = n = 0
  587. for c in enc:
  588. r = self._BASE64_CHARS.index(c)
  589. cond = n % 4
  590. e = 64 * e + r if cond else r
  591. n += 1
  592. if cond:
  593. dec += chr(255 & e >> (-2 * n & 6))
  594. return dec
  595. def _unmask_url(self, mask_url, vk_id):
  596. if 'audio_api_unavailable' in mask_url:
  597. extra = mask_url.split('?extra=')[1].split('#')
  598. func, base = self._decode(extra[1]).split(chr(11))
  599. mask_url = list(self._decode(extra[0]))
  600. url_len = len(mask_url)
  601. indexes = [None] * url_len
  602. index = int(base) ^ vk_id
  603. for n in range(url_len - 1, -1, -1):
  604. index = (url_len * (n + 1) ^ index + n) % url_len
  605. indexes[n] = index
  606. for n in range(1, url_len):
  607. c = mask_url[n]
  608. index = indexes[url_len - 1 - n]
  609. mask_url[n] = mask_url[index]
  610. mask_url[index] = c
  611. mask_url = ''.join(mask_url)
  612. return mask_url
  613. def _real_extract(self, url):
  614. post_id = self._match_id(url)
  615. webpage = self._download_payload('wkview', post_id, {
  616. 'act': 'show',
  617. 'w': 'wall' + post_id,
  618. })[1]
  619. uploader = clean_html(get_element_by_class('PostHeaderTitle__authorName', webpage))
  620. entries = []
  621. for audio in re.findall(r'data-audio="([^"]+)', webpage):
  622. audio = self._parse_json(unescapeHTML(audio), post_id)
  623. if not audio['url']:
  624. continue
  625. title = unescapeHTML(audio.get('title'))
  626. artist = unescapeHTML(audio.get('artist'))
  627. entries.append({
  628. 'id': f'{audio["owner_id"]}_{audio["id"]}',
  629. 'title': join_nonempty(artist, title, delim=' - '),
  630. 'thumbnails': try_call(lambda: [{'url': u} for u in audio['coverUrl'].split(',')]),
  631. 'duration': int_or_none(audio.get('duration')),
  632. 'uploader': uploader,
  633. 'artist': artist,
  634. 'track': title,
  635. 'formats': [{
  636. 'url': audio['url'],
  637. 'ext': 'm4a',
  638. 'vcodec': 'none',
  639. 'acodec': 'mp3',
  640. 'container': 'm4a_dash',
  641. }],
  642. })
  643. entries.extend(self.url_result(urljoin(url, entry), VKIE) for entry in set(re.findall(
  644. r'<a[^>]+href=(?:["\'])(/video(?:-?[\d_]+)[^"\']*)',
  645. get_element_html_by_id('wl_post_body', webpage))))
  646. return self.playlist_result(
  647. entries, post_id, join_nonempty(uploader, f'Wall post {post_id}', delim=' - '),
  648. clean_html(get_element_by_class('wall_post_text', webpage)))
  649. class VKPlayBaseIE(InfoExtractor):
  650. _BASE_URL_RE = r'https?://(?:vkplay\.live|live\.vkplay\.ru)/'
  651. _RESOLUTIONS = {
  652. 'tiny': '256x144',
  653. 'lowest': '426x240',
  654. 'low': '640x360',
  655. 'medium': '852x480',
  656. 'high': '1280x720',
  657. 'full_hd': '1920x1080',
  658. 'quad_hd': '2560x1440',
  659. }
  660. def _extract_from_initial_state(self, url, video_id, path):
  661. webpage = self._download_webpage(url, video_id)
  662. video_info = traverse_obj(self._search_json(
  663. r'<script[^>]+\bid="initial-state"[^>]*>', webpage, 'initial state', video_id),
  664. path, expected_type=dict)
  665. if not video_info:
  666. raise ExtractorError('Unable to extract video info from html inline initial state')
  667. return video_info
  668. def _extract_formats(self, stream_info, video_id):
  669. formats = []
  670. for stream in traverse_obj(stream_info, (
  671. 'data', 0, 'playerUrls', lambda _, v: url_or_none(v['url']) and v['type'])):
  672. url = stream['url']
  673. format_id = str_or_none(stream['type'])
  674. if format_id in ('hls', 'live_hls', 'live_playback_hls') or '.m3u8' in url:
  675. formats.extend(self._extract_m3u8_formats(url, video_id, m3u8_id=format_id, fatal=False))
  676. elif format_id == 'dash':
  677. formats.extend(self._extract_mpd_formats(url, video_id, mpd_id=format_id, fatal=False))
  678. elif format_id in ('live_dash', 'live_playback_dash'):
  679. self.write_debug(f'Not extracting unsupported format "{format_id}"')
  680. else:
  681. formats.append({
  682. 'url': url,
  683. 'ext': 'mp4',
  684. 'format_id': format_id,
  685. **parse_resolution(self._RESOLUTIONS.get(format_id)),
  686. })
  687. return formats
  688. def _extract_common_meta(self, stream_info):
  689. return traverse_obj(stream_info, {
  690. 'id': ('id', {str_or_none}),
  691. 'title': ('title', {str}),
  692. 'release_timestamp': ('startTime', {int_or_none}),
  693. 'thumbnail': ('previewUrl', {url_or_none}),
  694. 'view_count': ('count', 'views', {int_or_none}),
  695. 'like_count': ('count', 'likes', {int_or_none}),
  696. 'categories': ('category', 'title', {str}, {lambda x: [x] if x else None}),
  697. 'uploader': (('user', ('blog', 'owner')), 'nick', {str}),
  698. 'uploader_id': (('user', ('blog', 'owner')), 'id', {str_or_none}),
  699. 'duration': ('duration', {int_or_none}),
  700. 'is_live': ('isOnline', {bool}),
  701. 'concurrent_view_count': ('count', 'viewers', {int_or_none}),
  702. }, get_all=False)
  703. class VKPlayIE(VKPlayBaseIE):
  704. _VALID_URL = rf'{VKPlayBaseIE._BASE_URL_RE}(?P<username>[^/#?]+)/record/(?P<id>[\da-f-]+)'
  705. _TESTS = [{
  706. 'url': 'https://vkplay.live/zitsmann/record/f5e6e3b5-dc52-4d14-965d-0680dd2882da',
  707. 'info_dict': {
  708. 'id': 'f5e6e3b5-dc52-4d14-965d-0680dd2882da',
  709. 'ext': 'mp4',
  710. 'title': 'Atomic Heart (пробуем!) спасибо подписчику EKZO!',
  711. 'uploader': 'ZitsmanN',
  712. 'uploader_id': '13159830',
  713. 'release_timestamp': 1683461378,
  714. 'release_date': '20230507',
  715. 'thumbnail': r're:https://[^/]+/public_video_stream/record/f5e6e3b5-dc52-4d14-965d-0680dd2882da/preview',
  716. 'duration': 10608,
  717. 'view_count': int,
  718. 'like_count': int,
  719. 'categories': ['Atomic Heart'],
  720. },
  721. 'params': {'skip_download': 'm3u8'},
  722. }, {
  723. 'url': 'https://live.vkplay.ru/lebwa/record/33a4e4ce-e3ef-49db-bb14-f006cc6fabc9/records',
  724. 'only_matching': True,
  725. }]
  726. def _real_extract(self, url):
  727. username, video_id = self._match_valid_url(url).groups()
  728. record_info = traverse_obj(self._download_json(
  729. f'https://api.vkplay.live/v1/blog/{username}/public_video_stream/record/{video_id}', video_id, fatal=False),
  730. ('data', 'record', {dict}))
  731. if not record_info:
  732. record_info = self._extract_from_initial_state(url, video_id, ('record', 'currentRecord', 'data'))
  733. return {
  734. **self._extract_common_meta(record_info),
  735. 'id': video_id,
  736. 'formats': self._extract_formats(record_info, video_id),
  737. }
  738. class VKPlayLiveIE(VKPlayBaseIE):
  739. _VALID_URL = rf'{VKPlayBaseIE._BASE_URL_RE}(?P<id>[^/#?]+)/?(?:[#?]|$)'
  740. _TESTS = [{
  741. 'url': 'https://vkplay.live/bayda',
  742. 'info_dict': {
  743. 'id': 'f02c321e-427b-408d-b12f-ae34e53e0ea2',
  744. 'ext': 'mp4',
  745. 'title': r're:эскапизм крута .*',
  746. 'uploader': 'Bayda',
  747. 'uploader_id': '12279401',
  748. 'release_timestamp': 1687209962,
  749. 'release_date': '20230619',
  750. 'thumbnail': r're:https://[^/]+/public_video_stream/12279401/preview',
  751. 'view_count': int,
  752. 'concurrent_view_count': int,
  753. 'like_count': int,
  754. 'categories': ['EVE Online'],
  755. 'live_status': 'is_live',
  756. },
  757. 'skip': 'livestream',
  758. 'params': {'skip_download': True},
  759. }, {
  760. 'url': 'https://live.vkplay.ru/lebwa',
  761. 'only_matching': True,
  762. }]
  763. def _real_extract(self, url):
  764. username = self._match_id(url)
  765. stream_info = self._download_json(
  766. f'https://api.vkplay.live/v1/blog/{username}/public_video_stream', username, fatal=False)
  767. if not stream_info:
  768. stream_info = self._extract_from_initial_state(url, username, ('stream', 'stream', 'data', 'stream'))
  769. formats = self._extract_formats(stream_info, username)
  770. if not formats and not traverse_obj(stream_info, ('isOnline', {bool})):
  771. raise UserNotLive(video_id=username)
  772. return {
  773. **self._extract_common_meta(stream_info),
  774. 'formats': formats,
  775. }