Skillshare 字幕下载 v11

支持下载 Skillshare 的字幕 (.srt 文件) 以及 下载视频 (.mp4)

目前为 2022-12-23 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name:zh-CN Skillshare 字幕下载 v11
  3. // @name Skillshare Subtitle Downloader v11
  4. // @namespace https://gf.qytechs.cn/users/5711
  5. // @version 11
  6. // @description:zh-CN 支持下载 Skillshare 的字幕 (.srt 文件) 以及 下载视频 (.mp4)
  7. // @description Download Skillshare Subtitle as .srt file
  8. // @author Zheng Cheng
  9. // @match https://www.skillshare.com/*/classes/*
  10. // @run-at document-start
  11. // @grant unsafeWindow
  12. // @license MIT
  13. // @supportURL guokrfans@gmail.com
  14. // ==/UserScript==
  15.  
  16. // 最初写于 2020-2-24
  17. // [工作原理]
  18. // 1. 下载一门课程全部字幕(多个 .srt 文件)原理是利用 transcriptCuesArray,字幕数据都在里面,进行格式转换+保存即可
  19. // 2. 下载当前视频的字幕(一个 .srt 文件)原理是用 videojs 里 textTracks 的 cue,进行格式转换+保存即可
  20.  
  21.  
  22. // [更新日志]
  23. // v11(2022-12-23): Fix: 1. "download button" now showing. 2. Changed all button text from Chinese to English.
  24. // v9(2021-3-11): 改进了批量下载视频时,文件名的构造方法
  25. // v8(2021-3-11): 整理代码
  26. // v7(2021-3-11): 可以下载视频,包括当前视频,以及从当前视频开始一直到最后一个视频。
  27.  
  28.  
  29. // [注意]
  30. // 必须 @run-at document-start,因为批量下载视频的部分需要尽早拦截 XMLHttpRequest.prototype.setRequestHeader
  31.  
  32. (function () {
  33. 'use strict';
  34.  
  35. // ==== 这一段的目的,是为了把一个请求头存起来,之后我们自己发请求时用得上 ====
  36. // 有的 http 请求,比如获得视频信息的那个 https://edge.api.brightcove.com/playback/v1/accounts/3695997568001/videos/6173466475001
  37. // 需要一个请求头,Accept: application/json;pk=BCpkADawqM2OOcM6njnM7hf9EaK6lIFlqiXB0iWjqGWUQjU7R8965xUvIQNqdQbnDTLz0IAO7E6Ir2rIbXJtFdzrGtitoee0n1XXRliD-RH9A-svuvNW9qgo3Bh34HEZjXjG4Nml4iyz3KqF
  38. // pk 是 policy key 的缩写(因为响应头里面明确写了 Policy-Key-Raw )
  39. // 由于这个 Accept: application/json;pk= 完全无法在页面中获取到(应该是用代码生成的)我们只能使用这样的截取方式
  40.  
  41. var request_header_accept = null
  42. XMLHttpRequest.prototype.real_setRequestHeader = XMLHttpRequest.prototype.setRequestHeader
  43. XMLHttpRequest.prototype.setRequestHeader = function (header, value) {
  44. if (header == 'Accept' && value.startsWith('application/json;pk=')) {
  45. request_header_accept = value; // 如果两个条件都对,就存起来
  46. // console.log(`找到了!`);
  47. // console.log(request_header_accept);
  48. // 还好找到了这样的方式,不然我就去写 Chrome Extension 了(麻烦多了,安装也麻烦,开发也麻烦)
  49. }
  50. this.real_setRequestHeader(header, value);
  51. }
  52. // ==== 这一段的目的,是为了把一个请求头存起来,之后我们自己发请求时用得上 ====
  53.  
  54.  
  55. // 初始化变量
  56. var sessions = null; // 存一个 sessions 数组 (Skillshare 提供的)
  57. var transcriptCuesArray = null; // 用途同上
  58. var div = document.createElement('div');
  59. var button = document.createElement('button'); // 下载全部字幕的按钮
  60. var button2 = document.createElement('button'); // 下载当前视频字幕的按钮
  61. var button3 = document.createElement('button'); // 下载当前视频的按钮
  62. var button4 = document.createElement('button'); // 下载全部视频的按钮
  63. var title_element = document.querySelector("div.class-details-header-title"); // 标题元素
  64.  
  65. function insertAfter(newNode, referenceNode) {
  66. referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
  67. }
  68.  
  69. // 注入
  70. async function inject_our_script() {
  71. transcriptCuesArray = await get_transcriptCuesArray();
  72. var subtitle_ids = Object.keys(transcriptCuesArray); // ['3150718', '3150719', '3150720', ...]
  73. var subtitle_count = subtitle_ids.length
  74.  
  75. // 此按钮点击后:下载这门课的所有字幕 (得到多个文件)
  76. var button_text = `Download All Subtitle (${subtitle_count} .srt)`;
  77. button.textContent = button_text;
  78. button.addEventListener('click', download_subtitles);
  79.  
  80. // 此按钮点击后:下载当前视频的一个字幕 (得到一个文件)
  81. button2.textContent = get_download_current_episode_button_text()
  82. button2.addEventListener('click', download_current_episode_subtitles);
  83.  
  84. // 此按钮点击后:下载当前视频
  85. button3.textContent = get_download_current_video_button_text()
  86. button3.addEventListener('click', download_current_episode_video);
  87.  
  88. var button_css = `
  89. font-size: 16px;
  90. padding: 4px 18px;
  91. `;
  92.  
  93. var button2_css = `
  94. font-size: 16px;
  95. padding: 4px 18px;
  96. margin-left: 10px;
  97. `;
  98.  
  99. var div_css = `
  100. margin-bottom: 10px;
  101. `;
  102.  
  103. button.setAttribute('style', button_css);
  104. button2.setAttribute('style', button2_css);
  105. button3.setAttribute('style', button2_css);
  106. div.setAttribute('style', div_css);
  107.  
  108. div.appendChild(button);
  109. div.appendChild(button2);
  110. div.appendChild(button3);
  111.  
  112. // 按钮4
  113. button4.textContent = "Starting from current video, download all video til the very last one"
  114. button4.addEventListener('click', download_all_video);
  115. button4.setAttribute('style', button2_css);
  116. div.appendChild(button4);
  117.  
  118. insertAfter(div, title_element);
  119. }
  120.  
  121. // 下载当前这集视频
  122. function download_current_episode_video() {
  123. var vjs = videojs(document.querySelector('video'))
  124. var video_link = find_video_link(vjs.mediainfo.sources)
  125. if (video_link != null) {
  126. var filename = `${get_filename()}.mp4`
  127. fetch(video_link)
  128. .then(res => res.blob())
  129. .then(blob => {
  130. downloadString(blob, 'video/mp4', filename);
  131. });
  132. }
  133. }
  134.  
  135. // 下载单个视频, 用法参照其他地方
  136. function download_video(video_link, filetype, filename) {
  137. return new Promise((resolve, reject) => {
  138. fetch(video_link)
  139. .then(res => res.blob())
  140. .then(blob => {
  141. downloadString(blob, filetype, filename);
  142. resolve(true);
  143. }).catch(err => reject(err));
  144. })
  145. }
  146.  
  147. // 输入: sources 数组, 来自于网络请求的返回
  148. // 输出: (字符串) 一个视频链接
  149. function find_video_link(sources) {
  150. var video_link = null;
  151.  
  152. // 在数组里找到 *.mp4 的链接
  153. var array = sources;
  154. for (var i = 0; i < array.length; i++) {
  155. var s = array[i];
  156. if (s.container && s.container == 'MP4' && s.height >= 720) {
  157. video_link = s.src;
  158. break;
  159. }
  160. }
  161.  
  162. return video_link
  163. }
  164.  
  165. // 把 cue 遍历一下,得到一个特定格式的对象数组
  166. function get_current_episode_content_array() {
  167. var vjs = videojs(document.querySelector('video'))
  168. var cues = vjs.textTracks()[0].cues
  169. var array = []
  170. for (var i = 0; i < cues.length; i++) {
  171. var cue = cues[i]
  172. var obj = {
  173. start: cue.startTime,
  174. end: cue.endTime,
  175. text: cue.text,
  176. }
  177. array.push(obj);
  178. }
  179. return array;
  180. }
  181.  
  182. // 下载当前集字幕
  183. async function download_current_episode_subtitles() {
  184. var array = get_current_episode_content_array()
  185. var srt = parse_content_array_to_SRT(array);
  186. var filename = `${get_filename()}.srt`
  187. downloadString(srt, "text/plain", filename);
  188. }
  189.  
  190. // CSRF
  191. function csrf() {
  192. return SS.serverBootstrap.parentClassData.formData.csrfTokenValue
  193. }
  194.  
  195. // 拿到当前课程的 URL (不带任何参数或者 section,不带 /projects 或 /transcripts 在 URL 最后)
  196. function course_url() {
  197. var url1 = SS.serverBootstrap.loginPopupRedirectTo
  198. var url2 = window.location.origin + window.location.pathname
  199. if (url1) {
  200. return url1
  201. } else {
  202. return url2
  203. }
  204. // return document.querySelector('meta[property="og:url"]').content // 这个不可靠
  205. // 比如:
  206. // https://www.skillshare.com/classes/Logo-Design-Mastery-The-Full-Course/1793713747
  207. }
  208.  
  209. // 返回一个 URL
  210. function json_url() {
  211. return `${course_url()}/transcripts?format=json`
  212. // https://www.skillshare.com/classes/Logo-Design-Mastery-The-Full-Course/1793713747/transcripts?format=json
  213. }
  214.  
  215. // 发 http 请求,拿到 transcriptCuesArray
  216. // 调用例子:var result = await get_transcriptCuesArray();
  217. async function get_transcriptCuesArray() {
  218. return new Promise(function (resolve, reject) {
  219. var url = json_url()
  220. fetch(url, {
  221. headers: {
  222. 'x-csrftoken': csrf(),
  223. 'accept': 'application/json, text/javascript, */*; q=0.01'
  224. }
  225. })
  226. .then(response => response.json())
  227. .then(data => {
  228. resolve(data.transcriptCuesArray)
  229. }).catch(e => {
  230. reject(e);
  231. })
  232. })
  233. }
  234.  
  235. // 输入: id
  236. // 输出: sessions 数组里的一个对象
  237. function id_to_obj(id) {
  238. var array = sessions
  239. for (var i = 0; i < array.length; i++) {
  240. var one = array[i];
  241. if (one.id == id) {
  242. return one
  243. }
  244. }
  245. return null
  246. }
  247.  
  248. // 输入: video_id
  249. // 输出: session 里那条纪录
  250. function video_id_to_obj(video_id) {
  251. var string = `bc:${video_id}` // videoId: "bc:6053324155001"
  252. var array = sessions
  253. for (var i = 0; i < array.length; i++) {
  254. var one = array[i];
  255. if (one.videoId == string) {
  256. return one
  257. }
  258. }
  259. return null
  260. }
  261.  
  262. // 输入: video_id
  263. // 输出: 合适的视频文件名 (但是没后缀,后缀自己加)
  264. function get_filename_by_video_id(video_id) {
  265. var obj = video_id_to_obj(video_id)
  266. var rank = obj.displayRank
  267. var filename = `${rank}. ${safe_filename(obj.title)}`
  268. return filename
  269. }
  270.  
  271. // 输入: id
  272. // 输出: 文件名 (xxx.srt)
  273. function get_filename_by_id(id) {
  274. var obj = id_to_obj(id);
  275. var rank = obj.displayRank;
  276. var title = obj.title
  277. var filename = `${rank}. ${safe_filename(title)}.srt`
  278. return filename
  279. }
  280.  
  281. // 下载所有集的字幕
  282. async function download_subtitles() {
  283. for (let key in transcriptCuesArray) {
  284. var value = transcriptCuesArray[key];
  285. var srt = parse_content_array_to_SRT(value.content);
  286. var filename = get_filename_by_id(key)
  287. downloadString(srt, "text/plain", filename);
  288.  
  289. await sleep(1000);
  290. // 如果不 sleep,下载大概11个文件就会停下来(不会报错,但就是停下来了)
  291. // sleep 可以把全部42个文件下载下来
  292. }
  293. }
  294.  
  295. // 从当前视频开始下载
  296. async function download_all_video() {
  297. // 当前 session
  298. var startingSession = unsafeWindow.SS.serverBootstrap.pageData.videoPlayerData.startingSession
  299.  
  300. // 全部 session
  301. var sessions = unsafeWindow.SS.serverBootstrap.pageData.videoPlayerData.units[0].sessions
  302.  
  303. for (var i = 0; i < sessions.length; i++) {
  304. var session = sessions[i];
  305. var displayRank = session.displayRank;
  306. if (displayRank >= startingSession.displayRank) { // 从当前视频开始下载(包括当前视频)一直下载到最后一个
  307. var video_id = session.videoId.split(':')[1]; // 视频 ID
  308. var response = await get_single_video_data(video_id); // 拿到 JSON 返回
  309.  
  310. var video_link = find_video_link(response.sources); // 视频链接
  311. var filename = `${get_filename_by_video_id(response.id)}.mp4`; // 文件名
  312.  
  313. if (video_link.startsWith('http://')) {
  314. video_link = video_link.replace('http://', 'https://')
  315. }
  316.  
  317. // console.log(video_link);
  318. // console.log(filename);
  319. // console.log(response);
  320. // console.log('--------------');
  321. await download_video(video_link, 'video/mp4', filename); // 下载
  322. }
  323. }
  324. }
  325.  
  326. // 返回账户 ID
  327. // 举例: 3695997568001
  328. function get_account_id() {
  329. return unsafeWindow.SS.serverBootstrap.pageData.videoPlayerData.brightcoveAccountId;
  330. }
  331.  
  332. // 输入: id
  333. // 输出: JSON (视频数据)
  334. function get_single_video_data(video_id) {
  335. // https://edge.api.brightcove.com/playback/v1/accounts/3695997568001/videos/6234379709001
  336. var account_id = get_account_id();
  337. var url = `https://edge.api.brightcove.com/playback/v1/accounts/${account_id}/videos/${video_id}`
  338. return new Promise(function (resolve, reject) {
  339. fetch(url, {
  340. headers: {
  341. "Accept": request_header_accept
  342. }
  343. })
  344. .then(response => response.json())
  345. .then(data => {
  346. resolve(data)
  347. }).catch(e => {
  348. reject(e);
  349. })
  350. })
  351. }
  352.  
  353. // 把指定格式的数组
  354. // 转成 SRT
  355. // 返回字符串
  356. // var content_array_example = [
  357. // {
  358. // start: 0,
  359. // end: 8.3,
  360. // text: "hi"
  361. // },
  362. // // ...
  363. // ];
  364. function parse_content_array_to_SRT(content_array) {
  365. if (content_array === '') {
  366. return false;
  367. }
  368.  
  369. var result = '';
  370. var BOM = '\uFEFF';
  371. result = BOM + result; // store final SRT result
  372.  
  373. for (var i = 0; i < content_array.length; i++) {
  374. var one = content_array[i];
  375. var index = i + 1;
  376. var content = one.text
  377. var start = one.start
  378. var end = one.end
  379.  
  380. // we want SRT format:
  381. /*
  382. 1
  383. 00:00:01,939 --> 00:00:04,350
  384. everybody Craig Adams here I'm a
  385. 2
  386. 00:00:04,350 --> 00:00:06,720
  387. filmmaker on YouTube who's digging
  388. */
  389. var new_line = "\n";
  390. result = result + index + new_line;
  391. // 1
  392.  
  393. var start_time = process_time(parseFloat(start));
  394. var end_time = process_time(parseFloat(end));
  395. result = result + start_time;
  396. result = result + ' --> ';
  397. result = result + end_time + new_line;
  398. // 00:00:01,939 --> 00:00:04,350
  399.  
  400. result = result + content + new_line + new_line;
  401. }
  402. return result;
  403. }
  404.  
  405.  
  406. // 处理时间. 比如 start="671.33" start="37.64" start="12" start="23.029"
  407. // 处理成 srt 时间, 比如 00:00:00,090 00:00:08,460 00:10:29,350
  408. function process_time(s) {
  409. s = s.toFixed(3);
  410. // 超棒的函数, 不论是整数还是小数都给弄成3位小数形式
  411. // 举个柚子:
  412. // 671.33 -> 671.330
  413. // 671 -> 671.000
  414. // 注意函数会四舍五入. 具体读文档
  415.  
  416. var array = s.split('.');
  417. // 把开始时间根据句号分割
  418. // 671.330 会分割成数组: [671, 330]
  419.  
  420. var Hour = 0;
  421. var Minute = 0;
  422. var Second = array[0]; // 671
  423. var MilliSecond = array[1]; // 330
  424. // 先声明下变量, 待会把这几个拼好就行了
  425.  
  426. // 我们来处理秒数. 把"分钟"和"小时"除出来
  427. if (Second >= 60) {
  428. Minute = Math.floor(Second / 60);
  429. Second = Second - Minute * 60;
  430. // 把 秒 拆成 分钟和秒, 比如121秒, 拆成2分钟1秒
  431.  
  432. Hour = Math.floor(Minute / 60);
  433. Minute = Minute - Hour * 60;
  434. // 把 分钟 拆成 小时和分钟, 比如700分钟, 拆成11小时40分钟
  435. }
  436. // 分钟,如果位数不够两位就变成两位,下面两个if语句的作用也是一样。
  437. if (Minute < 10) {
  438. Minute = '0' + Minute;
  439. }
  440. // 小时
  441. if (Hour < 10) {
  442. Hour = '0' + Hour;
  443. }
  444. // 秒
  445. if (Second < 10) {
  446. Second = '0' + Second;
  447. }
  448. return Hour + ':' + Minute + ':' + Second + ',' + MilliSecond;
  449. }
  450.  
  451. function sleep(ms) {
  452. return new Promise(resolve => setTimeout(resolve, ms));
  453. }
  454.  
  455. // copy from: https://gist.github.com/danallison/3ec9d5314788b337b682
  456. // Example downloadString(srt, "text/plain", filename);
  457. function downloadString(text, fileType, fileName) {
  458. var blob = new Blob([text], {
  459. type: fileType
  460. });
  461. var a = document.createElement('a');
  462. a.download = fileName;
  463. a.href = URL.createObjectURL(blob);
  464. a.dataset.downloadurl = [fileType, a.download, a.href].join(':');
  465. a.style.display = "none";
  466. document.body.appendChild(a);
  467. a.click();
  468. document.body.removeChild(a);
  469. setTimeout(function () {
  470. URL.revokeObjectURL(a.href);
  471. }, 11500);
  472. }
  473.  
  474. // 切换了视频会触发这个事件
  475. // 实测好像点其他地方也会触发这个事件,
  476. document.addEventListener("selectionchange", function () {
  477. button2.textContent = get_download_current_episode_button_text()
  478. })
  479.  
  480. function get_download_current_episode_button_text() {
  481. return `Download Subtitle (.srt) for this episode`
  482. // return `下载当前字幕 (${get_filename()}.srt)`
  483. }
  484.  
  485. function get_download_current_video_button_text() {
  486. return `Download Video (.mp4) for this episode`
  487. }
  488.  
  489. // 返回当前正在播放的视频标题
  490. function get_current_title() {
  491. var li = document.querySelector('li.session-item.active')
  492. var title = li.querySelector('.session-item-title')
  493. return title.innerText;
  494. }
  495.  
  496. // 转换成安全的文件名
  497. function safe_filename(string) {
  498. return string.replace(':', '-')
  499. }
  500.  
  501. // 当前视频的安全文件名
  502. function get_filename() {
  503. return safe_filename(get_current_title())
  504. }
  505.  
  506. // 程序入口
  507. function main() {
  508. // 如果有标题才执行
  509. title_element = document.querySelector("div.class-details-header-title");
  510. if (title_element) {
  511. inject_our_script();
  512. sessions = unsafeWindow.SS.serverBootstrap.pageData.unitsData.units[0].sessions
  513. }
  514. }
  515.  
  516. setTimeout(main, 2000);
  517. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址