BBC iPlayer video download

This script allows to save videos from BBC iPlayer.

当前为 2017-01-14 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name BBC iPlayer video download
  3. // @namespace http://andrealazzarotto.com/
  4. // @include http://www.bbc.co.uk/*
  5. // @version 3.8
  6. // @description This script allows to save videos from BBC iPlayer.
  7. // @copyright 2015+, Andrea Lazzarotto - GPLv3 License
  8. // @require http://code.jquery.com/jquery-latest.min.js
  9. // @grant GM_xmlhttpRequest
  10. // @connect edgesuite.net
  11. // @connect bbc.co.uk
  12. // @connect akamaihd.net
  13. // @connect llnwd.net
  14. // @license GPL version 3 or any later version; http://www.gnu.org/copyleft/gpl.html
  15. // ==/UserScript==
  16.  
  17. var added = 0;
  18. var config_url = 'http://www.bbc.co.uk/iplayer/config/windows-phone';
  19. var mediaselector;
  20. var vpid_qualities = {};
  21.  
  22. var get_title = function(name) {
  23. var title = name || $('meta[property="og:title"]').attr('content') || 'output';
  24. return title.replace(/\W+/g, '_');
  25. };
  26.  
  27. var get_JSON = function(url) {
  28. var data = $.ajax({
  29. url: url,
  30. method: 'GET',
  31. async: false
  32. });
  33. console.log(data);
  34. return $.parseJSON(data.responseText);
  35. };
  36.  
  37. var get_XML = function(url, callback) {
  38. GM_xmlhttpRequest({
  39. method: 'GET',
  40. url: url,
  41. onload: function(responseDetails) {
  42. var r = responseDetails.responseText;
  43. var doc = $.parseXML(r);
  44. var $xml = $(doc);
  45. callback($xml);
  46. }
  47. });
  48. };
  49.  
  50. var get_text = function(url, callback) {
  51. GM_xmlhttpRequest({
  52. method: 'GET',
  53. url: url,
  54. onload: function(responseDetails) {
  55. var r = responseDetails.responseText;
  56. callback(r);
  57. }
  58. });
  59. };
  60.  
  61. var place_link_box = function(element, id) {
  62. element.after('<div id="' + id + '" />');
  63. $('#' + id).css({
  64. 'padding': '.75em',
  65. 'margin': '25px auto',
  66. 'width': $('#player-outer-outer').width(),
  67. 'border': '1px solid #444',
  68. 'background-color': '#252525',
  69. 'color': 'white',
  70. 'font-family': 'sans-serif',
  71. 'box-sizing': 'border-box',
  72. 'font-size': '0.85rem'
  73. });
  74. };
  75.  
  76. var appendURL = function(element, id, vpid, ext, kind, title) {
  77. var extension = ext || 'mp4';
  78. var type = kind || 'video';
  79. var codec = type == 'video' ? ' -codec copy -qscale 0 -bsf:a aac_adtstoasc ' : ' ';
  80. var tool = 'ffmpeg';
  81. var safe_title = get_title(title);
  82. $("#" + id).remove();
  83. element.after('<div id=' + id + '"></div>');
  84. place_link_box(element, id);
  85.  
  86. var sizes = [];
  87. var keys = Object.keys(vpid_qualities[vpid]);
  88. for (var k in keys)
  89. sizes.push(parseInt(keys[k]));
  90. sizes.sort((function(a,b) { return b-a; }));
  91. console.log(sizes);
  92. $('#' + id).append('<h4>Quality level: <select /></h4>');
  93. $('#' + id).append('<p>To record the ' + type + ', use <code>' + tool + '</code> with the following command line:</p>');
  94.  
  95. for (var i in sizes) {
  96. var label = parseInt(sizes[i]);
  97. var url = vpid_qualities[vpid][label];
  98. url = url.replace("http://", "https://");
  99. $('#' + id).append('<div id="wrapper-' + i + '">' +
  100. '<pre>' + tool + ' -v 16 -stats -i "' + url + '"' + codec + safe_title + '.' + extension + '</pre>' +
  101. '<p>Alternatively, you may also try to record the <a href="' + url + '">M3U8 stream URL</a> with VLC.</p>' +
  102. '</div>');
  103. $('#' + id + ' select').append('<option value="' + i + '">' + label + "</option>");
  104. }
  105. $('#' + id + ' div[id*=wrapper]').hide();
  106. $('#' + id + ' #wrapper-0').show();
  107.  
  108. $('#' + id + ' select').on('change', function() {
  109. var index = this.value;
  110. $('#' + id + ' div[id*=wrapper]').hide();
  111. $('#' + id + ' #wrapper-' + index).show();
  112. });
  113.  
  114. $('#' + id + ' pre, #' + id + ' code').css({
  115. 'white-space': 'normal',
  116. 'word-break': 'break-word',
  117. 'font-size': $('#direct-link p').css('font-size'),
  118. 'margin': '.75em 0',
  119. 'padding': '.75em',
  120. 'background-color': '#444',
  121. 'font-family': 'monospace'
  122. });
  123. $('#' + id + ' code').css('padding','.25em');
  124. $('#' + id + ' p:last-child').css('margin-bottom', '0');
  125.  
  126. return id;
  127. };
  128.  
  129. var append_directURL = function(element, url, ext) {
  130. place_link_box(element, 'direct-link');
  131. $('#direct-link').append('<p>Yay! We have a direct link to a file. :D</p>' +
  132. '<p><a href="' + url + '">Click here to open/download (' + ext + ')</a></p>');
  133. };
  134.  
  135. var m3u8_qualities = function(contents, m3u8_url) {
  136. var lines = contents.split('\n');
  137. var streams = {};
  138. for (var i = 0; i < lines.length - 1; i++) {
  139. var h = lines[i];
  140. var u = lines[i+1];
  141. if(h.indexOf('#EXT-X-STREAM-INF') === 0 && u.indexOf('m3u8') > 0) {
  142. var divider = 'RESOLUTION=';
  143. if (h.indexOf(divider) < 0)
  144. divider = 'BANDWIDTH=';
  145. var q = parseInt(h.split(divider)[1].split('x')[0]);
  146. if(h.indexOf('audio-') > 0) {
  147. var audio_value = parseInt(lines[i].split('audio-')[1].split(',')[0].split('"')[0].replace(/[^0-9]*/g, ''));
  148. if (!isNaN(audio_value))
  149. q = q*100 + audio_value;
  150. }
  151. if (u.indexOf('://') > 0)
  152. streams[q] = u;
  153. else
  154. streams[q] = m3u8_url.split('/').slice(0,-1).join('/') + '/' + u;
  155. i++;
  156. }
  157. }
  158. return streams;
  159. };
  160.  
  161. var get_biggest = function(dict) {
  162. var s = 0;
  163. var o = null;
  164. for(var key in dict) {
  165. key = parseInt(key) || 0;
  166. if (key > s) {
  167. s = key;
  168. o = dict[key];
  169. }
  170. }
  171. return {'size': s, 'object': o};
  172. };
  173.  
  174. var render_piece = function(html) {
  175. var tree = $(html);
  176. if (!tree.length)
  177. return '';
  178. var output = [];
  179. var nodes = tree[0].childNodes;
  180. var hyph = html.toString().indexOf('<span') > 0 ? '- ' : '';
  181. for (var o = 0; o < nodes.length; o++) {
  182. if (nodes[o].toString().indexOf('Text') > 0)
  183. output.push(hyph + nodes[o].textContent);
  184. else {
  185. var name = nodes[o].tagName.toLowerCase();
  186. switch(name) {
  187. case 'br':
  188. output.push(' ');
  189. break;
  190. case 'span':
  191. output.push('\n' + hyph);
  192. output.push(render_piece(nodes[o]));
  193. output.push('\n');
  194. break;
  195. }
  196. }
  197. }
  198. var joined = output.join('');
  199. joined = joined.replace(/\s+\n/, '\n').replace(/(^\n|\n$)/, '');
  200. joined = joined.replace(/\n+/, '\n').replace(/\s+/, ' ');
  201. return joined;
  202. };
  203.  
  204. var render_p = function(html, id) {
  205. var tree = $(html);
  206. var begin = tree.attr('begin').replace('.', ',');
  207. var end = tree.attr('end').replace('.', ',');
  208. return id + '\n' +
  209. begin + ' --> ' + end + '\n' +
  210. render_piece(html);
  211. };
  212.  
  213. var handle_subtitles = function(subURL, element_id) {
  214. if (!subURL)
  215. return;
  216.  
  217. GM_xmlhttpRequest({
  218. method: 'GET',
  219. url: subURL,
  220. onload: function(responseDetails) {
  221. var r = responseDetails.responseText;
  222. var doc = $.parseXML(r);
  223. var $xml = $(doc);
  224.  
  225. var srt_list = [];
  226. $xml.find('p').each(function(index, value){
  227. srt_list.push(render_p(value.outerHTML, index+1));
  228. });
  229.  
  230. $('#' + element_id + ' #subtitles').remove();
  231. $('#' + element_id + ' p:last-child').css('margin-bottom', 'auto');
  232. $('#' + element_id).append('<ul id="subtitles"><li><a id="srt-link">Download converted subtitles (SRT)</a></li>' +
  233. '<li><a href="' + subURL + '">Download original subtitles (TTML)</a></li></ul>');
  234. $('#srt-link').attr('href', 'data:text/plain;charset=utf-8,' +
  235. encodeURIComponent(srt_list.join('\n\n'))).attr('download', get_title() + '.srt');
  236. $('#' + element_id + ' a').css({
  237. 'color': 'white',
  238. 'font-weight': 'bold'
  239. });
  240. $('#' + element_id + ' ul').css({
  241. 'list-style': 'initial',
  242. 'padding-left': '2em',
  243. 'margin-top': '.5em'
  244. });
  245. }
  246. });
  247. };
  248.  
  249. var fix_final_url = function(final_url, high_quality) {
  250. var old_pieces = final_url.split(',');
  251. var pieces = [old_pieces[0], old_pieces[1], old_pieces[old_pieces.length-1]];
  252. var p = 1;
  253.  
  254. var strpiv = 'kbps/';
  255.  
  256. var template = pieces[p];
  257. var cutter = template.indexOf(strpiv);
  258. var pivot = template.substring(0, cutter).lastIndexOf('/');
  259. template = template.substring(0, pivot+1);
  260.  
  261. var hq_piece = high_quality;
  262. cutter = hq_piece.indexOf(strpiv);
  263. pivot = hq_piece.substring(0, cutter).lastIndexOf('/');
  264. hq_piece = hq_piece.substring(pivot+1, 1000).replace('.mp4', '');
  265.  
  266. console.log(pivot);
  267. console.log(hq_piece);
  268.  
  269. pieces[p] = [template, hq_piece].join('');
  270.  
  271. console.log(pieces);
  272.  
  273. return pieces.join(',');
  274. };
  275.  
  276. var place_and_update = function(vpid, m3u8, high_quality, size, selector, id, kind, video_title, subURL) {
  277. // compose the M3U8 stream URL
  278. get_text(m3u8, function(r) {
  279. var urls = r.split('\n').slice(1);
  280. console.log(m3u8);
  281. console.log(r);
  282. console.log(urls);
  283. var final_url = (m3u8.indexOf('prod_af') < 0 && urls[1].indexOf('_av.') > 0) ? urls[1] : m3u8;
  284. console.log('FINAL_URL: ' + final_url);
  285.  
  286. var ext = kind == 'video' ? 'mp4' : 'mp3';
  287. // fix the final url
  288. if ((kind == 'video' && final_url.indexOf(',') > 0) || final_url.indexOf('kbps') > 0)
  289. final_url = fix_final_url(final_url, high_quality);
  290. console.log('KIND: ' + kind);
  291.  
  292. // output the M3U8 URL
  293. if(final_url.indexOf('master.m3u8') > 0 && kind == 'video') {
  294. get_text(final_url, function(r) {
  295. var qualities = m3u8_qualities(r, final_url);
  296. var keys = Object.keys(qualities);
  297. for (var index in keys) {
  298. var key = parseInt(keys[index]);
  299. vpid_qualities[vpid][key] = qualities[key];
  300. }
  301. var element_id = appendURL($(selector), id, vpid, ext, kind, video_title);
  302. handle_subtitles(subURL, element_id);
  303. }); // get_text final_url
  304. }
  305. else {
  306. vpid_qualities[vpid][size] = final_url;
  307. var element_id = appendURL($(selector), id, vpid, ext, kind, video_title);
  308. console.log("SUBURL: " + subURL);
  309. handle_subtitles(subURL, element_id);
  310. }
  311. }); // get_text m3u8
  312. };
  313.  
  314. var handle_pid = function(vpid, selector, video_title) {
  315. // figure out the mediaselector URL
  316. var selector_mobile = mediaselector.replace('{vpid}', vpid);
  317. var selector_pc = selector_mobile.replace(/mobile-.*vpid/, 'pc/vpid');
  318. var id = 'direct-link-' + vpid + '-' + (added++);
  319. vpid_qualities[vpid] = {};
  320.  
  321. get_XML(selector_mobile, function($xml) {
  322. var media = {};
  323.  
  324. console.log('SELECTOR_MOBILE: ' + selector_mobile);
  325. // Exclude fake high-quality streams
  326. var urls = $xml.find('media[kind^="video"]:not(media[service*="streaming_concrete_combined"])');
  327. var kind = 'video';
  328. // Try to find at least low-quality streams
  329. if (!urls.length)
  330. urls = $xml.find('media[kind^="video"]');
  331. // Check for audio-only content
  332. if (!urls.length) {
  333. urls = $xml.find('media[kind^="audio"]');
  334. kind = 'audio';
  335. }
  336.  
  337. urls.each(function() {
  338. var bitrate = $(this).attr('bitrate');
  339. var href = $(this).find('connection:not([href*=akamaized])').attr('href');
  340. media[bitrate] = href;
  341. });
  342. var subURL = $xml.find('media[service="captions"] connection').attr('href');
  343. var m3u8_url = get_biggest(media);
  344. console.log("M3U8_URL: " + m3u8_url.object);
  345.  
  346. // get desktop data for higher quality
  347. get_XML(selector_pc, function($xml) {
  348. console.log('SELECTOR_PC: ' + selector_pc);
  349.  
  350. var media = {};
  351. var urls = $xml.find('media[kind^="' + kind + '"]');
  352.  
  353. urls.each(function() {
  354. var bitrate = $(this).attr('bitrate');
  355. var identifier = $(this).find('connection[application="ondemand"], ' +
  356. 'connection[application*="/e3"]').attr('identifier');
  357. if(identifier)
  358. media[bitrate] = identifier;
  359. });
  360.  
  361. var keys = Object.keys(media);
  362. for (var index in keys) {
  363. var key = parseInt(keys[index]);
  364. console.log("Quality: " + key);
  365. console.log("M3U8: " + media[key]);
  366. place_and_update(vpid, m3u8_url.object, media[key], key, selector, id, kind, video_title, subURL);
  367. }
  368. }); // get_xml selector_pc
  369. }); // get_xml selector_mobile
  370. };
  371.  
  372. var handle_embeds = function() {
  373. var present = $("div.smp-embed[data-vpid]").length;
  374. var loaded = $("div.smp-embed[data-vpid] > div").length;
  375.  
  376. if (present != loaded) {
  377. console.log("Waiting for embeds...");
  378. setTimeout(handle_embeds, 500);
  379. }
  380. else {
  381. console.log("Working on embeds");
  382. $("div.smp-embed[data-vpid] > div").each(function() {
  383. var el = $(this).parent();
  384. var vpid = el.data('vpid');
  385. var title = el.data('title');
  386. handle_pid(vpid, el.parent(), title);
  387. });
  388. }
  389. };
  390.  
  391. var handle_morph = function() {
  392. var payloads = unsafeWindow.Morph.payloads;
  393. if (!payloads) {
  394. console.log("Waiting for Morph...");
  395. setTimeout(handle_morph, 1500);
  396. }
  397. else {
  398. console.log("[MORPH] " + payloads);
  399. for (var key in payloads) {
  400. if (key.indexOf("assetUri") < 0)
  401. continue;
  402. try {
  403. var identifiers = payloads[key].body.components[0].props.leadMedia.identifiers;
  404. console.log(identifiers);
  405. handle_pid(identifiers.vpid, $('#bbcMediaPlayer0').parent());
  406. }
  407. catch (err) {}
  408. }
  409. }
  410. };
  411.  
  412. $(document).ready(function(){
  413. var isRadio = (unsafeWindow.location.href.indexOf('radio/') > 0) && !!($('#empbox').length);
  414. var isProgramme = !!unsafeWindow.bbcProgrammes;
  415. var isMediator = !!$('script:contains("mediator.bind")').length;
  416. var isMorph = !!unsafeWindow.Morph;
  417.  
  418. console.log("isRadio: " + isRadio);
  419. console.log("isProgramme: " + isProgramme);
  420. console.log("isMediator: " + isMediator);
  421. console.log("isMorph: " + isMorph);
  422.  
  423. mediaselector = get_JSON(config_url).mediaselector;
  424.  
  425. if (isRadio) {
  426. var playlist = unsafeWindow.clipcontentPlaylist;
  427. var empconf = unsafeWindow.empConfig;
  428. var subdir = empconf.split('.co.uk/')[1].split('/')[0];
  429. if (playlist.indexOf('.xml') > 0) {
  430. GM_xmlhttpRequest({
  431. method: 'GET',
  432. url: playlist,
  433. onload: function(responseDetails) {
  434. var r = responseDetails.responseText;
  435. var doc = $.parseXML(r);
  436. var $xml = $(doc);
  437.  
  438. var media_files = $xml.find('media[kind="video"] > connection, media[kind="audio"] > connection');
  439. if (media_files.length) {
  440. var link = media_files.attr('href');
  441. append_directURL($('#empbox'), link, link.split('.co.uk/')[1].split('.')[1].toUpperCase());
  442. }
  443. }
  444. });
  445. }
  446. else if (playlist.indexOf('iplayer/playlist') > 0) {
  447. var parts = playlist.split('iplayer/playlist');
  448. var playlist_address = parts[0] + subdir + '/iplayer/playlist' + parts[1];
  449. $.getJSON(playlist_address, function(data) {
  450. console.log('VPID: ' + data.pid);
  451. handle_pid(data.pid, '#empbox');
  452. });
  453. }
  454. return;
  455. }
  456.  
  457. if (isProgramme) {
  458. var clipid = location.href.split("/")[4];
  459. var element = $("[data-pid]");
  460. if (element.length == 1) {
  461. clipid = element.data("pid");
  462. }
  463. $.getJSON('http://www.bbc.co.uk/programmes/' + clipid + '/playlist.json', function(data) {
  464. var vpid = data.defaultAvailableVersion.pid;
  465. console.log("VPID: " + vpid.toString());
  466. handle_pid(vpid, '.island .cf.component, .episode-playout, .island');
  467. });
  468. }
  469.  
  470. if (isMediator) {
  471. var spid = $('script:contains("mediator.bind")').html();
  472. var vpid = spid.split('vpid')[1].split('"')[2];
  473. handle_pid(vpid, '#player-outer-outer');
  474. }
  475.  
  476. if (isMorph)
  477. handle_morph();
  478.  
  479. handle_embeds();
  480.  
  481. }); // $(document).ready