Youtube Subtitle Downloader v36

Download Subtitles

  1. // ==UserScript==
  2. // @name Youtube Subtitle Downloader v36
  3. // @description Download Subtitles
  4. // @include https://*youtube.com/*
  5. // @author Cheng Zheng
  6. // @copyright 2009 Tim Smart; 2011 gw111zz; 2014~2023 Cheng Zheng;
  7. // @license GNU GPL v3.0 or later. http://www.gnu.org/copyleft/gpl.html
  8. // @require https://code.jquery.com/jquery-1.12.4.min.js
  9. // @version 36
  10. // @grant GM_xmlhttpRequest
  11. // @grant unsafeWindow
  12. // @namespace https://greasyfork.org/users/5711
  13. // ==/UserScript==
  14.  
  15. /*
  16. [What is this?]
  17. This Tampermonkey script allows you to download Youtube "Automatic subtitle" and "closed subtitle".
  18.  
  19. [Note]
  20. If it doesn't work (rarely), try to refresh the page.
  21. If problem still exists after refreshing, send an email to guokrfans@gmail.com.
  22.  
  23. [Who built this?]
  24. Author : Cheng Zheng (郑诚)
  25. Email : guokrfans@gmail.com
  26. Github : https://github.com/1c7/Youtube-Auto-Subtitle-Download
  27.  
  28. [Note for Developers]
  29. 1. Some comments are written in Chinese.
  30. 2. This code handles both "Auto" and "Closed" subtitles.
  31.  
  32. [Test Video]
  33. https://www.youtube.com/watch?v=bkVsus8Ehxs
  34. This videos only has a closed English subtitle, with no auto subtitles.
  35.  
  36. https://www.youtube.com/watch?v=-WEqFzyrbbs
  37. no subtitle at all
  38.  
  39. https://www.youtube.com/watch?v=9AzNEG1GB-k
  40. have a lot of subtitles
  41.  
  42. https://www.youtube.com/watch?v=tqGkOvrKGfY
  43. 1:36:33 super long subtitle
  44.  
  45. [How does it work?]
  46. The code can be roughly divided into three parts:
  47. 1. Add a button on the page. (UI)
  48. 2. Detect if subtitle exists.
  49. 3. Convert subtitle format, then download.
  50.  
  51. [Test Enviroment]
  52. Works best on Chrome + Tampermonkey.
  53. There are plenty Chromium-based Browser, I do not guarantee this work on all of them;
  54.  
  55. 备注:
  56. 有时候不能用,是因为 jQuery 的 CDN 无法载入,解决办法是修改这一行
  57. // @require https://code.jquery.com/jquery-1.12.4.min.js
  58. 改成一个别的 jQuery 地址,比如
  59. https://cdn.bootcdn.net/ajax/libs/jquery/1.12.4/jquery.js
  60. https://cdn.staticfile.org/jquery/1.12.4/jquery.min.js
  61.  
  62. 更新日志
  63.  
  64. ## 2022年12月23号:升级到 v35
  65. 常规升级。把下载框挪到标题下面,之前太靠下了(放到了描述的下面)现在挪上去一点。
  66. */
  67.  
  68. ;(function () {
  69.  
  70. // Config
  71. var NO_SUBTITLE = 'No Subtitle'
  72. var HAVE_SUBTITLE = 'Download Subtitles'
  73. var TEXT_LOADING = 'Loading...'
  74. const BUTTON_ID =
  75. 'youtube-subtitle-downloader-by-1c7-latest-update-2022-decemeber-23'
  76. // Config
  77.  
  78. var HASH_BUTTON_ID = `#${BUTTON_ID}`
  79.  
  80. // initialize
  81. var first_load = true // indicate if first load this webpage or not
  82. var youtube_playerResponse_1c7 = null // for auto subtitle
  83. unsafeWindow.caption_array = [] // store all subtitle
  84.  
  85. $(document).ready(function () {
  86. make_sure_it_load_properly_before_continue()
  87. })
  88.  
  89. async function wait_until_element_exists(element_identifier) {
  90. var retry_count = 0
  91. var RETRY_LIMIT = 50
  92. return new Promise(function (resolve, reject) {
  93. var intervalID = setInterval(function () {
  94. try {
  95. var element = document.querySelector(element_identifier)
  96. if (element != null) {
  97. resolve(true)
  98. } else {
  99. retry_count = retry_count + 1
  100. // console.log(`重试次数 ${retry_count}`);
  101. if (retry_count > RETRY_LIMIT) {
  102. clearInterval(intervalID)
  103. reject(false)
  104. }
  105. }
  106. } catch (error) {
  107. reject(false)
  108. }
  109. }, 330)
  110. })
  111. }
  112.  
  113. async function make_sure_it_load_properly_before_continue() {
  114. var id = new_Youtube_2022_UI_element_identifier()
  115. var result = await wait_until_element_exists(id)
  116. if (result) {
  117. init_UI()
  118. }
  119. }
  120.  
  121. // trigger when loading new page
  122. // (actually this would also trigger when first loading, that's not what we want, that's why we need to use firsr_load === false)
  123. // (new Material design version would trigger this "yt-navigate-finish" event. old version would not.)
  124. var body = document.getElementsByTagName('body')[0]
  125. body.addEventListener('yt-navigate-finish', function (event) {
  126. // 2021-8-9 测试结果:yt-navigate-finish 可以正常触发
  127. if (current_page_is_video_page() === false) {
  128. return
  129. }
  130. youtube_playerResponse_1c7 = event.detail.response.playerResponse // for auto subtitle
  131. unsafeWindow.caption_array = [] // clean up (important, otherwise would have more and more item and cause error)
  132.  
  133. // if use click to another page, init again to get correct subtitle
  134. if (first_load === false) {
  135. remove_subtitle_download_button()
  136. init_UI()
  137. }
  138. })
  139.  
  140. // 我们用这个元素判断是不是 2022 年新 UI 。
  141. // return Element;
  142. function new_Youtube_2022_UI_element() {
  143. return document.querySelector(new_Youtube_2022_UI_element_identifier())
  144. }
  145.  
  146. function new_Youtube_2022_UI_element_identifier() {
  147. var document_querySelector = '#owner.item.style-scope.ytd-watch-metadata'
  148. return document_querySelector
  149. }
  150.  
  151. // return true / false
  152. // Detect [new version UI(material design)] OR [old version UI]
  153. // I tested this, accurated.
  154. function new_material_design_version() {
  155. var old_title_element = document.getElementById('watch7-headline')
  156. if (old_title_element) {
  157. return false
  158. } else {
  159. return true
  160. }
  161. }
  162.  
  163. // return true / false
  164. function current_page_is_video_page() {
  165. return get_url_video_id() !== null
  166. }
  167.  
  168. // return string like "RW1ChiWyiZQ", from "https://www.youtube.com/watch?v=RW1ChiWyiZQ"
  169. // or null
  170. function get_url_video_id() {
  171. return getURLParameter('v')
  172. }
  173.  
  174. //https://stackoverflow.com/questions/11582512/how-to-get-url-parameters-with-javascript/11582513#11582513
  175. function getURLParameter(name) {
  176. return (
  177. decodeURIComponent(
  178. (new RegExp('[?|&]' + name + '=' + '([^&;]+?)(&|#|;|$)').exec(
  179. location.search
  180. ) || [null, ''])[1].replace(/\+/g, '%20')
  181. ) || null
  182. )
  183. }
  184.  
  185. function remove_subtitle_download_button() {
  186. $(HASH_BUTTON_ID).remove()
  187. }
  188.  
  189. // 初始化
  190. function init_UI() {
  191. var html_element = get_main_UI_element()
  192.  
  193. // 旧版 UI
  194. var old_anchor_element = document.getElementById('watch7-headline')
  195. if (old_anchor_element != null) {
  196. old_anchor_element.appendChild(html_element)
  197. }
  198.  
  199. // 新版 UI
  200. var anchor = document.querySelector('#above-the-fold #title')
  201. if (anchor) {
  202. anchor.appendChild(html_element)
  203. }
  204.  
  205. first_load = false
  206. }
  207.  
  208. function get_main_UI_element() {
  209. var div = document.createElement('div'),
  210. select = document.createElement('select'),
  211. option = document.createElement('option')
  212.  
  213. var css_div = `display: table;
  214. margin-top:4px;
  215. border: 1px solid rgb(0, 183, 90);
  216. cursor: pointer; color: rgb(255, 255, 255);
  217. border-top-left-radius: 3px;
  218. border-top-right-radius: 3px;
  219. border-bottom-right-radius: 3px;
  220. border-bottom-left-radius: 3px;
  221. background-color: #00B75A;
  222. `
  223. div.setAttribute('style', css_div)
  224.  
  225. div.id = BUTTON_ID
  226.  
  227. select.id = 'captions_selector'
  228. select.disabled = true
  229. let css_select = `display:block;
  230. border: 1px solid rgb(0, 183, 90);
  231. cursor: pointer;
  232. color: rgb(255, 255, 255);
  233. background-color: #00B75A;
  234. padding: 4px;
  235. `
  236. select.setAttribute('style', css_select)
  237.  
  238. option.textContent = TEXT_LOADING
  239. option.selected = true
  240. select.appendChild(option)
  241.  
  242. // 下拉菜单里,选择一项后触发下载
  243. select.addEventListener(
  244. 'change',
  245. function () {
  246. download_subtitle(this)
  247. },
  248. false
  249. )
  250.  
  251. div.appendChild(select) // put <select> into <div>
  252.  
  253. // put the div into page: new material design
  254. var title_element = document.querySelectorAll(
  255. '.title.style-scope.ytd-video-primary-info-renderer'
  256. )
  257. if (title_element) {
  258. $(title_element[0]).after(div)
  259. }
  260.  
  261. load_language_list(select)
  262.  
  263. // <a> element is for download
  264. var a = document.createElement('a')
  265. a.style.cssText = 'display:none;'
  266. a.setAttribute('id', 'ForSubtitleDownload')
  267. var body = document.getElementsByTagName('body')[0]
  268. body.appendChild(a)
  269.  
  270. return div
  271. }
  272.  
  273. // trigger when user select <option>
  274. async function download_subtitle(selector) {
  275. // if user select first <option>, we just return, do nothing.
  276. if (selector.selectedIndex == 0) {
  277. return
  278. }
  279.  
  280. var caption = caption_array[selector.selectedIndex - 1]
  281. // because first <option> is for display, so index - 1
  282.  
  283. var result = null
  284. var filename = null // 保存文件名
  285.  
  286. // if user choose auto subtitle
  287. if (caption.lang_code == 'AUTO') {
  288. result = await get_auto_subtitle()
  289. filename = get_file_name(get_auto_subtitle_name())
  290. } else {
  291. // closed subtitle
  292. let lang_code = caption.lang_code
  293. let lang_name = caption.lang_name
  294. result = await get_closed_subtitle(lang_code)
  295. filename = get_file_name(lang_name)
  296. }
  297.  
  298. let srt = parse_youtube_XML_to_SRT(result)
  299. downloadString(srt, 'text/plain', filename)
  300.  
  301. // After download, select first <option>
  302. selector.options[0].selected = true
  303. }
  304.  
  305. // Return something like: "(English)How Did Python Become A Data Science Powerhouse?.srt"
  306. function get_file_name(x) {
  307. // var method_1 = '(' + x + ')' + document.title + '.srt'; // 如果有通知数,文件名也会带上,比较烦,这种方式不好
  308. // var method_2 = '(' + x + ')' + get_title() + '.srt';
  309. var method_3 = `(${x})${get_title()}_video_id_${get_video_id()}.srt`
  310. return method_3
  311. }
  312.  
  313. // 拿完整字幕的 XML
  314. // async function get_closed_subtitles() {
  315. // var list_url = 'https://video.google.com/timedtext?hl=en&v=' + get_url_video_id() + '&type=list';
  316. // // Example: https://video.google.com/timedtext?hl=en&v=if36bqHypqk&type=list
  317. // return new Promise(function (resolve, reject) {
  318. // GM_xmlhttpRequest({
  319. // method: 'GET',
  320. // url: list_url,
  321. // onload: function (xhr) {
  322. // resolve(xhr.responseText)
  323. // }
  324. // })
  325. // })
  326. // }
  327.  
  328. // detect if "auto subtitle" and "closed subtitle" exist
  329. // and add <option> into <select>
  330. async function load_language_list(select) {
  331. // auto
  332. var auto_subtitle_exist = false
  333.  
  334. // closed
  335. var closed_subtitle_exist = false
  336.  
  337. // get auto subtitle
  338. var auto_subtitle_url = get_auto_subtitle_xml_url()
  339. if (auto_subtitle_url != false) {
  340. auto_subtitle_exist = true
  341. }
  342.  
  343. var captionTracks = get_captionTracks()
  344. if (
  345. captionTracks != undefined &&
  346. typeof captionTracks === 'object' &&
  347. captionTracks.length > 0
  348. ) {
  349. closed_subtitle_exist = true
  350. }
  351.  
  352. // if no subtitle at all, just say no and stop
  353. if (auto_subtitle_exist == false && closed_subtitle_exist == false) {
  354. select.options[0].textContent = NO_SUBTITLE
  355. disable_download_button()
  356. return false
  357. }
  358.  
  359. // if at least one type of subtitle exist
  360. select.options[0].textContent = HAVE_SUBTITLE
  361. select.disabled = false
  362.  
  363. var option = null // for <option>
  364. var caption_info = null // for our custom object
  365.  
  366. // if auto subtitle exist
  367. if (auto_subtitle_exist) {
  368. caption_info = {
  369. lang_code: 'AUTO', // later we use this to know if it's auto subtitle
  370. lang_name: get_auto_subtitle_name(), // for display only
  371. }
  372. caption_array.push(caption_info)
  373.  
  374. option = document.createElement('option')
  375. option.textContent = caption_info.lang_name
  376. select.appendChild(option)
  377. }
  378.  
  379. // if closed_subtitle_exist
  380. if (closed_subtitle_exist) {
  381. for (var i = 0, il = captionTracks.length; i < il; i++) {
  382. var caption = captionTracks[i]
  383. if (caption.kind == 'asr') {
  384. continue
  385. }
  386. let lang_code = caption.languageCode
  387. let lang_translated = caption.name.simpleText
  388. let lang_name = lang_code_to_local_name(lang_code, lang_translated)
  389. caption_info = {
  390. lang_code: lang_code,
  391. lang_name: lang_name,
  392. }
  393. caption_array.push(caption_info)
  394. // 加到 caption_array 里, 一个全局变量, 待会要靠它来下载
  395. option = document.createElement('option')
  396. option.textContent = caption_info.lang_name
  397. select.appendChild(option)
  398. }
  399. }
  400. }
  401.  
  402. function disable_download_button() {
  403. $(HASH_BUTTON_ID)
  404. .css('border', '#95a5a6')
  405. .css('cursor', 'not-allowed')
  406. .css('background-color', '#95a5a6')
  407. $('#captions_selector')
  408. .css('border', '#95a5a6')
  409. .css('cursor', 'not-allowed')
  410. .css('background-color', '#95a5a6')
  411.  
  412. if (new_material_design_version()) {
  413. $(HASH_BUTTON_ID).css('padding', '6px')
  414. } else {
  415. $(HASH_BUTTON_ID).css('padding', '5px')
  416. }
  417. }
  418.  
  419. // 处理时间. 比如 start="671.33" start="37.64" start="12" start="23.029"
  420. // 处理成 srt 时间, 比如 00:00:00,090 00:00:08,460 00:10:29,350
  421. function process_time(s) {
  422. s = s.toFixed(3)
  423. // 超棒的函数, 不论是整数还是小数都给弄成3位小数形式
  424. // 举个柚子:
  425. // 671.33 -> 671.330
  426. // 671 -> 671.000
  427. // 注意函数会四舍五入. 具体读文档
  428.  
  429. var array = s.split('.')
  430. // 把开始时间根据句号分割
  431. // 671.330 会分割成数组: [671, 330]
  432.  
  433. var Hour = 0
  434. var Minute = 0
  435. var Second = array[0] // 671
  436. var MilliSecond = array[1] // 330
  437. // 先声明下变量, 待会把这几个拼好就行了
  438.  
  439. // 我们来处理秒数. 把"分钟"和"小时"除出来
  440. if (Second >= 60) {
  441. Minute = Math.floor(Second / 60)
  442. Second = Second - Minute * 60
  443. // 把 秒 拆成 分钟和秒, 比如121秒, 拆成2分钟1秒
  444.  
  445. Hour = Math.floor(Minute / 60)
  446. Minute = Minute - Hour * 60
  447. // 把 分钟 拆成 小时和分钟, 比如700分钟, 拆成11小时40分钟
  448. }
  449. // 分钟,如果位数不够两位就变成两位,下面两个if语句的作用也是一样。
  450. if (Minute < 10) {
  451. Minute = '0' + Minute
  452. }
  453. // 小时
  454. if (Hour < 10) {
  455. Hour = '0' + Hour
  456. }
  457. // 秒
  458. if (Second < 10) {
  459. Second = '0' + Second
  460. }
  461. return Hour + ':' + Minute + ':' + Second + ',' + MilliSecond
  462. }
  463.  
  464. // copy from: https://gist.github.com/danallison/3ec9d5314788b337b682
  465. // Thanks! https://github.com/danallison
  466. // work in Chrome 66
  467. // test passed: 2018-5-19
  468. function downloadString(text, fileType, fileName) {
  469. var blob = new Blob([text], {
  470. type: fileType,
  471. })
  472. var a = document.createElement('a')
  473. a.download = fileName
  474. a.href = URL.createObjectURL(blob)
  475. a.dataset.downloadurl = [fileType, a.download, a.href].join(':')
  476. a.style.display = 'none'
  477. document.body.appendChild(a)
  478. a.click()
  479. document.body.removeChild(a)
  480. setTimeout(function () {
  481. URL.revokeObjectURL(a.href)
  482. }, 1500)
  483. }
  484.  
  485. // https://css-tricks.com/snippets/javascript/unescape-html-in-js/
  486. // turn HTML entity back to text, example: &quot; should be "
  487. function htmlDecode(input) {
  488. var e = document.createElement('div')
  489. e.class =
  490. 'dummy-element-for-tampermonkey-Youtube-Subtitle-Downloader-script-to-decode-html-entity'
  491. e.innerHTML = input
  492. return e.childNodes.length === 0 ? '' : e.childNodes[0].nodeValue
  493. }
  494.  
  495. // return URL or null;
  496. // later we can send a AJAX and get XML subtitle
  497. function get_auto_subtitle_xml_url() {
  498. try {
  499. var captionTracks = get_captionTracks()
  500. for (var index in captionTracks) {
  501. var caption = captionTracks[index]
  502. if (caption.kind === 'asr') {
  503. return captionTracks[index].baseUrl
  504. }
  505. // ASR – A caption track generated using automatic speech recognition.
  506. // https://developers.google.com/youtube/v3/docs/captions
  507. }
  508. return false
  509. } catch (error) {
  510. return false
  511. }
  512. }
  513.  
  514. async function get_auto_subtitle() {
  515. var url = get_auto_subtitle_xml_url()
  516. if (url == false) {
  517. return false
  518. }
  519. var result = await get(url)
  520. return result
  521. }
  522.  
  523. async function get_closed_subtitle(lang_code) {
  524. try {
  525. var captionTracks = get_captionTracks()
  526. for (var i in captionTracks) {
  527. var caption = captionTracks[i]
  528. if (caption.languageCode === lang_code && caption.kind != 'asr') {
  529. // 必须写 caption.kind != 'asr'
  530. // 否则会下载2个字幕文件(也就是这个分支会进来2次)
  531. // 因为 lang_code 是 "en" 会 match 2条纪录,一条是自动字幕,一条是完整字幕
  532. // "自动字幕"那条是 kind=asr
  533. // "完整字幕"那条没有 kind 属性
  534. let url = captionTracks[i].baseUrl
  535. let result = await get(url)
  536. return result
  537. }
  538. }
  539. return false
  540. } catch (error) {
  541. return false
  542. }
  543. }
  544.  
  545. // Youtube return XML. we want SRT
  546. // input: Youtube XML format
  547. // output: SRT format
  548. function parse_youtube_XML_to_SRT(youtube_xml_string) {
  549. if (youtube_xml_string === '') {
  550. return false
  551. }
  552. var text = youtube_xml_string.getElementsByTagName('text')
  553. var result = ''
  554. var BOM = '\uFEFF'
  555. result = BOM + result // store final SRT result
  556. var len = text.length
  557. for (var i = 0; i < len; i++) {
  558. var index = i + 1
  559. var content = text[i].textContent.toString()
  560. content = content.replace(/(<([^>]+)>)/gi, '') // remove all html tag.
  561. var start = text[i].getAttribute('start')
  562. var end =
  563. parseFloat(text[i].getAttribute('start')) +
  564. parseFloat(text[i].getAttribute('dur'))
  565.  
  566. // 保留这段代码
  567. // 如果希望字幕的结束时间和下一行的开始时间相同(连在一起)
  568. // 可以取消下面的注释
  569. // if (i + 1 >= len) {
  570. // end = parseFloat(text[i].getAttribute('start')) + parseFloat(text[i].getAttribute('dur'));
  571. // } else {
  572. // end = text[i + 1].getAttribute('start');
  573. // }
  574.  
  575. // we want SRT format:
  576. /*
  577. 1
  578. 00:00:01,939 --> 00:00:04,350
  579. everybody Craig Adams here I'm a
  580.  
  581. 2
  582. 00:00:04,350 --> 00:00:06,720
  583. filmmaker on YouTube who's digging
  584. */
  585. var new_line = '\n'
  586. result = result + index + new_line
  587. // 1
  588.  
  589. var start_time = process_time(parseFloat(start))
  590. var end_time = process_time(parseFloat(end))
  591. result = result + start_time
  592. result = result + ' --> '
  593. result = result + end_time + new_line
  594. // 00:00:01,939 --> 00:00:04,350
  595.  
  596. content = htmlDecode(content)
  597. // turn HTML entity back to text. example: &#39; back to apostrophe (')
  598.  
  599. result = result + content + new_line + new_line
  600. // everybody Craig Adams here I'm a
  601. }
  602. return result
  603. }
  604.  
  605. // return "English (auto-generated)" or a default name;
  606. function get_auto_subtitle_name() {
  607. try {
  608. var captionTracks = get_captionTracks()
  609. for (var index in captionTracks) {
  610. var caption = captionTracks[index]
  611. if (typeof caption.kind === 'string' && caption.kind == 'asr') {
  612. return captionTracks[index].name.simpleText
  613. }
  614. }
  615. return 'Auto Subtitle'
  616. } catch (error) {
  617. return 'Auto Subtitle'
  618. }
  619. }
  620.  
  621. function get_youtube_data() {
  622. return document.getElementsByTagName('ytd-app')[0].data.playerResponse
  623. }
  624.  
  625. function get_captionTracks() {
  626. let data = get_youtube_data()
  627. var captionTracks =
  628. data?.captions?.playerCaptionsTracklistRenderer?.captionTracks
  629. return captionTracks
  630. }
  631.  
  632. // Input a language code, output that language name in current locale
  633. // 如果当前语言是中文简体, Input: "de" Output: 德语
  634. // if current locale is English(US), Input: "de" Output: "Germany"
  635. function lang_code_to_local_name(languageCode, fallback_name) {
  636. try {
  637. var captionTracks = get_captionTracks()
  638. for (var i in captionTracks) {
  639. var caption = captionTracks[i]
  640. if (caption.languageCode === languageCode) {
  641. let simpleText = captionTracks[i].name.simpleText
  642. if (simpleText) {
  643. return simpleText
  644. } else {
  645. return fallback_name
  646. }
  647. }
  648. }
  649. } catch (error) {
  650. return fallback_name
  651. }
  652. }
  653.  
  654. // 获取视频标题
  655. function get_title() {
  656. // 方法1:先尝试拿到标题
  657. var title_element = document.querySelector(
  658. 'h1.title.style-scope.ytd-video-primary-info-renderer'
  659. )
  660. if (title_element != null) {
  661. var title = title_element.innerText
  662. // 能拿到就返回
  663. if (title != undefined && title != null && title != '') {
  664. return title
  665. }
  666. }
  667. // 方法2:如果方法1失效用这个
  668. return ytplayer.bootstrapPlayerResponse.videoDetails.videoId // 这个会 delay, 如果页面跳转了,这个获得的标题还是旧的
  669. }
  670.  
  671. function get_video_id() {
  672. return ytplayer.bootstrapPlayerResponse.videoDetails.videoId
  673. }
  674.  
  675. // Usage: var result = await get(url)
  676. function get(url) {
  677. return $.ajax({
  678. url: url,
  679. type: 'get',
  680. success: function (r) {
  681. return r
  682. },
  683. fail: function (error) {
  684. return error
  685. },
  686. })
  687. }
  688.  
  689. const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
  690.  
  691. // 等待一个元素存在
  692. // https://stackoverflow.com/questions/5525071/how-to-wait-until-an-element-exists
  693. function waitForElm(selector) {
  694. return new Promise((resolve) => {
  695. if (document.querySelector(selector)) {
  696. return resolve(document.querySelector(selector))
  697. }
  698.  
  699. const observer = new MutationObserver((mutations) => {
  700. if (document.querySelector(selector)) {
  701. resolve(document.querySelector(selector))
  702. observer.disconnect()
  703. }
  704. })
  705.  
  706. observer.observe(document.body, {
  707. childList: true,
  708. subtree: true,
  709. })
  710. })
  711. }
  712.  
  713. })()