Skillshare 字幕下载

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

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