媒体流捕获

捕获HTML视频流,并下载的实现。理论上可以捕获任意在线视频或直播的媒体流

  1. // ==UserScript==
  2. // @name 媒体流捕获
  3. // @namespace https://github.com/Momo707577045/media-source-extract
  4. // @version 0.2.7
  5. // @description 捕获HTML视频流,并下载的实现。理论上可以捕获任意在线视频或直播的媒体流
  6. // @license AGPL-3.0
  7. // @author Momo707577045
  8. // @match *://*/*
  9. // @exclude http://blog.luckly-mjw.cn/tool-show/media-source-extract/player/player.html
  10. // @grant none
  11. // @run-at document-start
  12. // ==/UserScript==
  13.  
  14. (function () {
  15. 'use strict';
  16. (function () {
  17. if (document.getElementById('media-source-extract')) {
  18. return;
  19. }
  20.  
  21. // 轮询监听 iframe 的加载
  22. setInterval(() => {
  23. try {
  24. Array.prototype.forEach.call(document.getElementsByTagName('iframe'), (iframe) => {
  25. // 若 iframe 使用了 sandbox 进行操作约束,删除原有 iframe,拷贝其备份,删除 sandbox 属性,重新载入
  26. // 若 iframe 已载入,再修改 sandbox 属性,将修改无效。故通过新建 iframe 的方式绕过
  27. if (iframe.hasAttribute('sandbox')) {
  28. const parentNode = iframe.parentNode;
  29. const tempIframe = iframe.cloneNode();
  30. tempIframe.removeAttribute("sandbox");
  31. iframe.remove();
  32. parentNode.appendChild(tempIframe);
  33. }
  34. });
  35. } catch (error) {
  36. console.log(error);
  37. }
  38. }, 1000);
  39.  
  40.  
  41. let downloadDate = new Date(); // 流下载时间标识
  42. let isClose = false, isEndOfStream = false; // 是否关闭
  43. let isStreamDownload = false; // 是否使用流式下载
  44. let _sourceBufferList = []; // 媒体轨道
  45. const $showBtn = document.createElement('div'); // 展示按钮
  46. const $btnDownload = document.createElement('div'); // 下载按钮
  47. const $btnStreamDownload = document.createElement('div'); // 流式下载按钮
  48. const $downloadNum = document.createElement('div'); // 已捕获视频片段数
  49. const $tenRate = document.createElement('div'); // 十倍速播放
  50. const $closeBtn = document.createElement('div'); // 关闭
  51. const $container = document.createElement('div'); // 容器
  52. $closeBtn.innerHTML = `
  53. <img style="
  54. padding-top: 4px;
  55. width: 24px;
  56. display: inline-block;
  57. cursor: pointer;
  58. " src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAAk1BMVEUAAAD////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////ROyVeAAAAMHRSTlMA1Sq7gPribxkJx6Ey8onMsq+GTe10QF8kqJl5WEcvIBDc0sHAkkk1FgO2ZZ+dj1FHfPqwAAACNElEQVRIx6VW6ZqqMAwtFlEW2Rm3EXEfdZa+/9PdBEvbIVXu9835oW1yjiQlTWQE/iYPuTObOTzMNz4bQFRlY2FgnFXRC/o01mytiafP+BPvQZk56bcLSOXem1jpCy4QgXvRtlEVCARfUP65RM/hp29/+0R7eSbhoHlnffZ8h76e6x1tyw9mxXaJ3nfTVLd89hQr9NfGceJxfLIXmONh6eNNYftNSESRmgkHlEOjmhgBbYcEW08FFQN/ro6dvAczjhgXEdQP76xHEYxM+igQq259gLrCSlwbD3iDtTMy+A4Yuk0B6zV8c+BcO2OgFIp/UvJdG4o/Rp1JQYXeZFflPEFMfvugiFGFXN587YtgX7C8lRGFXPCGGYCCzlkoxJ4xqmi/jrIcdYYh5pwxiwI/gt7lDDFrcLiMKhBJ//W78ENsJgVUsV8wKpjZBXshM6cCW0jbRAilICFxIpgGMmmiWGHSIR6ViY+DPFaqSJCbQ5mbxoZLIlU0Al/cBj6N1uXfFI0okLppi69StmumSFQRP6oIKDedFi3vRDn3j6KozCZlu0DdJb3AupJXNLmqkk9+X9FEHLt1Jq8oi1H5n01AtRlvwQZQl9hmtPY4JEjMDs5ftWJN4Xr4lLrV2OHiUDHCPgvA/Tn/hP4zGUBfjZ3eLJ+NIOfHxi8CMoAQtYfmw93v01O0e7VlqqcCsXML3Vsu94cxnb4c7ML5chG8JIP9b38dENGaj3+x+TpiA/AL/fen8In7H8l3ZjdJQt2TAAAAAElFTkSuQmCC">`;
  59. $showBtn.innerHTML = `
  60. <img style="
  61. padding-top: 4px;
  62. width: 24px;
  63. display: inline-block;
  64. cursor: pointer;
  65. " src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADIBAMAAABfdrOtAAAAElBMVEUAAAD///////////////////8+Uq06AAAABXRSTlMA2kCAv5tF5NoAAAErSURBVHja7dzNasJAFIbhz8Tu7R0Eq/vQNHuxzL6YnPu/ldYpAUckxJ8zSnjfdTIPzHrOUawJdqmDJre1S/X7avigbM08kMgMSmt+iPWKbcwTsb3+KswXseOFLb2RnaTgjXTxtpwRq7XMgWz9kZ8cSKcwE6SX+SMGAgICAvJCyHdz2ud0pEx+/BpFaj2kEgQEBAQEBAQEBOT1kXWSkhbvk1vptOLs1LEWNrmVRgIBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBeTayTqpufogxduqM3q2AgICAgICAgICA3IOko4ZXkB/pqOHzhyZBQEBAQLIieVahtDNBDnrLgZT+yC4HUkmtN9JnWUiVZbVWliVhseCJdPqvCH5IV2tQNl4r6Bod+wWq9eeDik+xFQAAAABJRU5ErkJggg=="?`;
  66.  
  67. // 16倍速播放
  68. function _tenRatePlay(rate) {
  69. setTimeout(() => {
  70. let $domList = document.querySelectorAll('*');
  71. for (const $dom of $domList) {
  72. try {
  73. $dom.playbackRate = rate;
  74. $dom.muted = rate !== 1;
  75. } catch (e) {
  76. // console.error(e.message);
  77. }
  78. }
  79. });
  80. }
  81.  
  82. // 获取顶部 window title,因可能存在跨域问题,故使用 try catch 进行保护
  83. function getDocumentTitle() {
  84. let title = document.title;
  85. try {
  86. title = window.top.document.title;
  87. } catch (error) {
  88. console.log(error);
  89. }
  90. return title;
  91. }
  92.  
  93.  
  94. var _htm = _htm || {};
  95. (function () {
  96. var hm = document.createElement("script");
  97. hm.src = "https://hm.baidu.com/hm.js?1f12b0865d866ae1b93514870d93ce89";
  98. var s = document.querySelector("script");
  99. s.parentNode.insertBefore(hm, s);
  100. })();
  101.  
  102. // 流式下载
  103. function _streamDownload() {
  104. // 对应状态未下载结束的媒体轨道
  105. const remainSourceBufferList = [];
  106. for (const target of _sourceBufferList) {
  107. // 对应的 MSE 状态为已下载完成状态
  108. if (target.MSEInstance.readyState === 'ended') {
  109. target.streamWriter.close();
  110. } else {
  111. remainSourceBufferList.push(target);
  112. }
  113. }
  114. // 流式下载,释放已下载完成的媒体轨道,回收内存
  115. _sourceBufferList = remainSourceBufferList;
  116. showTip('视频已下载');
  117. // 重置状态
  118. downloadDate = new Date();
  119. isStreamDownload = false;
  120. $btnDownload.style.display = $btnStreamDownload.style.display = 'block';
  121. $downloadNum.innerHTML = `已捕获 0 个片段`;
  122. isEndOfStream = false;
  123. }
  124.  
  125. // 普通下载
  126. function _download() {
  127. const date = new Date();
  128. for (const target of _sourceBufferList) {
  129. const mime = target.mime.split(';')[0];
  130. const type = mime.split("/");
  131. const fileBlob = new Blob(target.bufferList, {type: mime}); // 创建一个Blob对象,并设置文件的 MIME 类型
  132. const a = document.createElement('a');
  133. a.download = `${type[0]}_${date.getFullYear().toString().padStart(4, '0')}${date.getMonth().toString().padStart(2, '0')}${date.getDay().toString().padStart(2, '0')}_${date.getHours().toString().padStart(2, '0')}${date.getMinutes().toString().padStart(2, '0')}${date.getSeconds().toString().padStart(2, '0')}.${type[1]}`;
  134. a.href = URL.createObjectURL(fileBlob);
  135. a.style.display = 'none';
  136. document.body.appendChild(a);
  137. // 禁止 click 事件冒泡,避免全局拦截
  138. a.onclick = function (e) {
  139. e.stopPropagation();
  140. }
  141. a.click();
  142. a.remove();
  143. if (isEndOfStream === true) {
  144. _sourceBufferList = [];
  145. $downloadNum.innerHTML = `已捕获 0 个片段`;
  146. isEndOfStream = false;
  147. showTip('视频已下载');
  148. }
  149. }
  150. }
  151.  
  152. for (const property of Object.getOwnPropertyNames(window)) {
  153. if (typeof window[property] === "function" && Boolean(window[property].prototype) && typeof window[property].prototype.addSourceBuffer === "function" && typeof window[property].prototype.endOfStream === "function") {
  154. doMediaSource(window[property]);
  155. }
  156. }
  157.  
  158. // 监听资源全部录取成功
  159. function doMediaSource(MediaSource) {
  160. let _endOfStream = MediaSource.prototype.endOfStream;
  161. MediaSource.prototype.endOfStream = function () {
  162. isEndOfStream = true;
  163. $downloadNum.innerHTML = `已捕获到终点,请下载`;
  164. showTip('已捕获到终点,请下载');
  165. if (isStreamDownload) {
  166. setTimeout(_streamDownload); // 等待 MediaSource 状态变更
  167. _endOfStream.call(this);
  168. return;
  169. }
  170. _endOfStream.call(this);
  171. };
  172.  
  173. // 录取资源
  174. let _addSourceBuffer = MediaSource.prototype.addSourceBuffer;
  175. MediaSource.prototype.addSourceBuffer = function (mime) {
  176. if (!isClose) {
  177. if (isEndOfStream && _sourceBufferList.length > 0) {
  178. if (confirm('检测到新的视频流,是否下载已捕获?\n点击“确定”下载已捕获\n点击“取消”重新捕获')) {
  179. _download();
  180. }
  181. }
  182. if (document.getElementById('media-source-capture') === null) {
  183. _appendDom();
  184. }
  185. let sourceBuffer = _addSourceBuffer.call(this, mime);
  186. let _append = sourceBuffer.appendBuffer;
  187. let bufferList = [];
  188. const _sourceBuffer = {
  189. mime,
  190. bufferList,
  191. MSEInstance: this,
  192. };
  193.  
  194. // 如果 streamSaver 已提前加载完成,则初始化对应的 streamWriter
  195. try {
  196. if (window.streamSaver) {
  197. const type = mime.split(';')[0].split('/')[1];
  198. _sourceBuffer.streamWriter = streamSaver.createWriteStream(`${type[0]}_${downloadDate.getFullYear().toString().padStart(4, '0')}${downloadDate.getMonth().toString().padStart(2, '0')}${downloadDate.getDay().toString().padStart(2, '0')}_${downloadDate.getHours().toString().padStart(2, '0')}${downloadDate.getMinutes().toString().padStart(2, '0')}${downloadDate.getSeconds().toString().padStart(2, '0')}.${type[1]}`).getWriter()
  199. }
  200. } catch (error) {
  201. console.error(error);
  202. }
  203.  
  204. _sourceBufferList.push(_sourceBuffer);
  205. sourceBuffer.appendBuffer = function (buffer) {
  206. $downloadNum.innerHTML = `已捕获 ${_sourceBufferList.length} 个片段`;
  207.  
  208. if (isStreamDownload && _sourceBuffer.streamWriter) { // 流式下载
  209. _sourceBuffer.streamWriter.write(new Uint8Array(buffer));
  210. } else { // 普通 blob 下载
  211. bufferList.push(buffer);
  212. }
  213. _append.call(this, buffer);
  214. }
  215. return sourceBuffer;
  216. }
  217. };
  218. MediaSource.prototype.addSourceBuffer.toString = function () {
  219. return 'function addSourceBuffer() { [native code] }';
  220. };
  221. }
  222.  
  223. // 添加操作的 dom
  224. function _appendDom() {
  225. if (document.getElementById('media-source-extract')) {
  226. return;
  227. }
  228. $container.setAttribute("style", 'position: fixed; top: 50px; right: 50px; text-align: right; z-index: 9999;');
  229. const baseStyle = 'float:right; clear:both; margin-top: 10px; padding: 0 20px; color: white; cursor: pointer; font-size: 16px; font-weight: bold; line-height: 40px; text-align: center; border-radius: 4px; background-color: #3498db; box-shadow: 0 3px 6px 0 rgba(0, 0, 0, 0.3);';
  230. $tenRate.innerHTML = '16倍速捕获(静音)';
  231. $downloadNum.innerHTML = '已捕获 0 个片段';
  232. $btnStreamDownload.innerHTML = '特大视频下载,边下载边保存';
  233. $btnDownload.innerHTML = '下载已捕获片段';
  234. $btnDownload.id = 'media-source-extract';
  235. $tenRate.setAttribute("style", baseStyle);
  236. $downloadNum.setAttribute("style", baseStyle);
  237. $btnDownload.setAttribute("style", baseStyle);
  238. $btnStreamDownload.setAttribute("style", baseStyle);
  239. $btnStreamDownload.style.display = 'none';
  240. $showBtn.setAttribute("style", 'float:right; clear:both; display: none; margin-top: 4px; height: 34px; width: 34px; line-height: 34px; text-align: center; border-radius: 4px; background-color: rgba(0, 0, 0, 0.5);');
  241. $closeBtn.setAttribute("style", 'float:right; clear:both; margin-top: 10px; height: 34px; width: 34px; line-height: 34px; text-align: center; display: inline-block; border-radius: 50%; background-color: rgba(0, 0, 0, 0.5);');
  242.  
  243. $btnDownload.addEventListener('click', _download);
  244. $tenRate.addEventListener('click', function () {
  245. if ($tenRate.innerHTML === '16倍速捕获(静音)') {
  246. _tenRatePlay(16);
  247. $tenRate.innerHTML = '恢复正常倍速音量';
  248. } else {
  249. _tenRatePlay(1);
  250. $tenRate.innerHTML = '16倍速捕获(静音)';
  251. }
  252. });
  253.  
  254. // 关闭控制面板
  255. $closeBtn.addEventListener('click', function () {
  256. $downloadNum.style.display = 'none';
  257. $btnStreamDownload.style.display = 'none';
  258. $btnDownload.style.display = 'none';
  259. $closeBtn.style.display = 'none';
  260. $tenRate.style.display = 'none';
  261. $showBtn.style.display = 'inline-block';
  262. _sourceBufferList = [];
  263. isClose = true;
  264. });
  265.  
  266. // 显示控制面板
  267. $showBtn.addEventListener('click', function () {
  268. if (!isStreamDownload) {
  269. $btnDownload.style.display = 'inline-block';
  270. $btnStreamDownload.style.display = 'inline-block';
  271. }
  272. $downloadNum.style.display = 'inline-block';
  273. $closeBtn.style.display = 'inline-block';
  274. $tenRate.style.display = 'inline-block';
  275. $showBtn.style.display = 'none';
  276. isClose = false;
  277. });
  278.  
  279. // 启动流式下载
  280. $btnStreamDownload.addEventListener('click', function () {
  281. isStreamDownload = true;
  282. $btnDownload.style.display = $btnStreamDownload.style.display = 'none';
  283. for (let sourceBuffer of _sourceBufferList) {
  284. if (!sourceBuffer.streamWriter) {
  285. const type = sourceBuffer.mime.split(';')[0].split('/');
  286. sourceBuffer.streamWriter = streamSaver.createWriteStream(`${type[0]}_${downloadDate.getFullYear().toString().padStart(4, '0')}${downloadDate.getMonth().toString().padStart(2, '0')}${downloadDate.getDay().toString().padStart(2, '0')}_${downloadDate.getHours().toString().padStart(2, '0')}${downloadDate.getMinutes().toString().padStart(2, '0')}${downloadDate.getSeconds().toString().padStart(2, '0')}.${type[1]}`).getWriter();
  287. sourceBuffer.bufferList.forEach(buffer => {
  288. sourceBuffer.streamWriter.write(new Uint8Array(buffer));
  289. });
  290. sourceBuffer.bufferList = [];
  291. }
  292. }
  293. })
  294.  
  295. document.getElementsByTagName('html')[0].insertBefore($container, document.getElementsByTagName('head')[0]);
  296. $container.appendChild($btnStreamDownload);
  297. $container.appendChild($downloadNum);
  298. $container.appendChild($btnDownload);
  299. $container.appendChild($tenRate);
  300. $container.appendChild($closeBtn);
  301. $container.appendChild($showBtn);
  302.  
  303. // 加载 stream 流式下载器
  304. try {
  305. let $streamSaver = document.createElement('script');
  306. $streamSaver.src = 'https://upyun.luckly-mjw.cn/lib/stream-saver.js';
  307. document.body.appendChild($streamSaver);
  308. $streamSaver.addEventListener('load', () => {
  309. $btnStreamDownload.style.display = 'inline-block';
  310. });
  311. } catch (error) {
  312. console.error(error);
  313. }
  314. }
  315. })();
  316.  
  317. if (window === top) {
  318. window.addEventListener("message", event => {
  319. if (event.source !== window) {
  320. try {
  321. let sql = event.data.split("\x00");
  322. if (sql[0] === "showTip" && sql[1].constructor === String) {
  323. if (sql[2]) showTip(sql[1], sql[2]);
  324. }
  325. } catch (e) {
  326. // 排除 下标越界错误 及 指令处理错误
  327. }
  328. }
  329. });
  330. }
  331.  
  332. function showTip(msg, style = ``) {
  333. // 该函数需要在top内运行,否则可能显示异常
  334. let root = document.querySelector(`:root`);
  335. if (window === top) {
  336. let tip = document.querySelector(":root>tip");
  337. if (tip && tip.nodeType === 1) {
  338. // 防止中途新的showTip事件创建多个tip造成卡顿
  339. tip.remove();
  340. }
  341. tip = document.createElement("tip");
  342. // pointer-events: none; 禁用鼠标事件,input标签使用 disabled='disabled' 禁用input标签
  343. tip.setAttribute("style", style + "pointer-events: none; opacity: 0; background-color: #222a; color: #fff; font-family: 微软雅黑,黑体,Droid Serif,Arial,sans-serif; font-size: 20px; text-align: center; padding: 6px; border-radius: 16px; position: fixed; transform: translate(-50%, -50%); left: 50%; bottom: 15%; z-index: 2147483647;");
  344. tip.innerHTML = "<style>@keyframes showTip {0%{opacity: 0;} 33.34%{opacity: 1;} 66.67%{opacity: 1;} 100%{opacity: 0;}}</style>\n" + msg;
  345. let time = msg.replace(new RegExp("\\s"), "").length / 2; // TODO 2个字/秒
  346. // cubic-bezier(起始点, 起始点偏移量, 结束点偏移量, 结束点),这里的 cubic-bezier函数 表示动画速度的变化规律
  347. tip.style.animation = "showTip " + (time > 2 ? time : 2) + "s cubic-bezier(0," + ((time - 1) > 0 ? (time - 1) / time : 0) + "," + (1 - ((time - 1) > 0 ? (time - 1) / time : 0)) + ",1) 1 normal";
  348. root.appendChild(tip);
  349. setTimeout(function () {
  350. try {
  351. tip.remove();
  352. } catch (e) {
  353. // 排除root没有找到tip
  354. }
  355. }, time * 1000);
  356. } else {
  357. top.postMessage("showTip\x00" + msg + "\x00" + style, "*");
  358. }
  359. }
  360. })();

QingJ © 2025

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