sp-stream-helper.user.js 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. // ==UserScript==
  2. // @name SharePoint Stream Helper
  3. // @namespace https://github.com/sp-stream-helper
  4. // @version 1.1
  5. // @description 一键复制 SharePoint Stream videomanifest 请求为 cURL 格式
  6. // @match https://*.sharepoint.com/personal/*/_layouts/*/stream.aspx*
  7. // @match https://*.sharepoint.com/sites/*/_layouts/*/stream.aspx*
  8. // @grant none
  9. // @run-at document-start
  10. // ==/UserScript==
  11. (function () {
  12. 'use strict';
  13. let capturedManifest = null;
  14. let btnReady = false;
  15. // ========================================
  16. // 1. 在 document-start 阶段拦截 fetch/XHR
  17. // @grant none 让脚本在页面上下文运行
  18. // ========================================
  19. // --- 拦截 fetch ---
  20. const origFetch = window.fetch;
  21. window.fetch = function (input, init) {
  22. try {
  23. const url = typeof input === 'string' ? input : (input?.url || '');
  24. const method = (init?.method || input?.method || 'GET').toUpperCase();
  25. const headers = extractHeaders(init?.headers || input?.headers);
  26. maybeCapture(method, url, headers);
  27. } catch (e) { /* 不影响原始请求 */ }
  28. return origFetch.apply(this, arguments);
  29. };
  30. // --- 拦截 XMLHttpRequest ---
  31. const origOpen = XMLHttpRequest.prototype.open;
  32. const origSetHeader = XMLHttpRequest.prototype.setRequestHeader;
  33. const origSend = XMLHttpRequest.prototype.send;
  34. XMLHttpRequest.prototype.open = function (method, url) {
  35. this._spH = { method: (method || 'GET').toUpperCase(), url: url || '', headers: {} };
  36. return origOpen.apply(this, arguments);
  37. };
  38. XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
  39. if (this._spH) this._spH.headers[name] = value;
  40. return origSetHeader.apply(this, arguments);
  41. };
  42. XMLHttpRequest.prototype.send = function () {
  43. if (this._spH) maybeCapture(this._spH.method, this._spH.url, this._spH.headers);
  44. return origSend.apply(this, arguments);
  45. };
  46. // ========================================
  47. // 2. 辅助函数
  48. // ========================================
  49. function extractHeaders(raw) {
  50. const h = {};
  51. if (!raw) return h;
  52. if (raw instanceof Headers) {
  53. raw.forEach((v, k) => { h[k] = v; });
  54. } else if (Array.isArray(raw)) {
  55. raw.forEach(([k, v]) => { h[k] = v; });
  56. } else if (typeof raw === 'object') {
  57. Object.keys(raw).forEach(k => { h[k] = raw[k]; });
  58. }
  59. return h;
  60. }
  61. function maybeCapture(method, url, headers) {
  62. if (!url || !url.includes('videomanifest')) return;
  63. if (method === 'OPTIONS') return;
  64. // 必须有 pac token
  65. const pacKey = Object.keys(headers).find(k => k.toLowerCase() === 'x-spopactoken');
  66. if (!pacKey) return;
  67. capturedManifest = { method, url, headers: { ...headers }, time: new Date().toLocaleTimeString() };
  68. console.log('[SP Helper] ✅ 捕获 videomanifest!', url.substring(0, 80) + '...');
  69. if (btnReady) updateButton();
  70. }
  71. // ========================================
  72. // 3. 备用方案: PerformanceObserver 监听
  73. // 如果 fetch/XHR 拦截失败,从 performance entries 拿 URL
  74. // 再从 g_fileInfo 拿 PAC token 拼 cURL
  75. // ========================================
  76. function startPerfObserver() {
  77. try {
  78. const obs = new PerformanceObserver((list) => {
  79. for (const entry of list.getEntries()) {
  80. if (entry.name && entry.name.includes('videomanifest') && !entry.name.includes('OPTIONS')) {
  81. if (!capturedManifest) {
  82. // 从 g_fileInfo 获取 PAC token
  83. const pacToken = getPacToken();
  84. if (pacToken) {
  85. capturedManifest = {
  86. method: 'GET',
  87. url: entry.name,
  88. headers: { 'x-spopactoken': pacToken },
  89. time: new Date().toLocaleTimeString(),
  90. source: 'PerformanceObserver',
  91. };
  92. console.log('[SP Helper] ✅ 通过 PerformanceObserver 捕获!');
  93. if (btnReady) updateButton();
  94. }
  95. }
  96. }
  97. }
  98. });
  99. obs.observe({ type: 'resource', buffered: true });
  100. } catch (e) {
  101. console.log('[SP Helper] PerformanceObserver 不可用:', e.message);
  102. }
  103. }
  104. function getPacToken() {
  105. // 方法1: g_fileInfo
  106. try {
  107. if (window.g_fileInfo) {
  108. const code = window.g_fileInfo['.driveAccessCode'] ||
  109. window.g_fileInfo['.driveAccessCodeV21'];
  110. if (code) return code;
  111. }
  112. } catch (e) {}
  113. // 方法2: 从页面 HTML 中提取
  114. try {
  115. const html = document.documentElement.innerHTML;
  116. const m = html.match(/["']\.driveAccessCode["']\s*:\s*["']([^"']+)["']/);
  117. if (m) return m[1];
  118. } catch (e) {}
  119. return null;
  120. }
  121. // ========================================
  122. // 4. 生成 cURL
  123. // ========================================
  124. function toCurl(req) {
  125. const parts = [`curl '${req.url}'`];
  126. // 从捕获的 headers 中提取 pac token 和 origin/referer
  127. let pacToken = '';
  128. let origin = '';
  129. let referer = '';
  130. for (const [name, value] of Object.entries(req.headers)) {
  131. const lower = name.toLowerCase();
  132. if (lower === 'x-spopactoken') pacToken = value;
  133. if (lower === 'origin') origin = value;
  134. if (lower === 'referer') referer = value;
  135. }
  136. // 固定 headers (模拟浏览器 Chrome 146)
  137. parts.push(` -H 'accept: */*'`);
  138. parts.push(` -H 'accept-language: zh-CN,zh;q=0.9'`);
  139. if (origin) parts.push(` -H 'origin: ${origin}'`);
  140. parts.push(` -H 'priority: u=1, i'`);
  141. if (referer) parts.push(` -H 'referer: ${referer}'`);
  142. parts.push(` -H 'sec-ch-ua: "Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"'`);
  143. parts.push(` -H 'sec-ch-ua-mobile: ?0'`);
  144. parts.push(` -H 'sec-ch-ua-platform: "Windows"'`);
  145. parts.push(` -H 'sec-fetch-dest: empty'`);
  146. parts.push(` -H 'sec-fetch-mode: cors'`);
  147. parts.push(` -H 'sec-fetch-site: cross-site'`);
  148. parts.push(` -H 'user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36'`);
  149. if (pacToken) {
  150. const safeVal = pacToken.replace(/'/g, "'\\''");
  151. parts.push(` -H 'x-spopactoken: ${safeVal}'`);
  152. }
  153. return parts.join(' \\\n');
  154. }
  155. // ========================================
  156. // 5. UI
  157. // ========================================
  158. function createButton() {
  159. const btn = document.createElement('div');
  160. btn.id = 'sp-helper-btn';
  161. btn.textContent = '⏳ 等待视频播放...';
  162. Object.assign(btn.style, {
  163. position: 'fixed',
  164. bottom: '20px',
  165. right: '20px',
  166. zIndex: '2147483647',
  167. padding: '10px 18px',
  168. borderRadius: '8px',
  169. background: '#555',
  170. color: '#ccc',
  171. fontSize: '14px',
  172. fontFamily: 'Consolas, "Courier New", monospace',
  173. cursor: 'not-allowed',
  174. boxShadow: '0 2px 12px rgba(0,0,0,0.4)',
  175. transition: 'all 0.3s',
  176. userSelect: 'none',
  177. });
  178. btn.addEventListener('click', onCopy);
  179. document.body.appendChild(btn);
  180. btnReady = true;
  181. return btn;
  182. }
  183. function updateButton() {
  184. let btn = document.getElementById('sp-helper-btn');
  185. if (!btn) btn = createButton();
  186. if (capturedManifest) {
  187. const src = capturedManifest.source ? ` [${capturedManifest.source}]` : '';
  188. btn.innerHTML = `📋 复制 cURL <span style="font-size:11px;opacity:0.6">${capturedManifest.time}${src}</span>`;
  189. btn.style.background = '#0078d4';
  190. btn.style.color = '#fff';
  191. btn.style.cursor = 'pointer';
  192. }
  193. }
  194. function onCopy() {
  195. if (!capturedManifest) return;
  196. const curl = toCurl(capturedManifest);
  197. navigator.clipboard.writeText(curl).then(() => {
  198. showCopied();
  199. }).catch(() => {
  200. // fallback
  201. const ta = document.createElement('textarea');
  202. ta.value = curl;
  203. ta.style.cssText = 'position:fixed;left:-9999px';
  204. document.body.appendChild(ta);
  205. ta.select();
  206. document.execCommand('copy');
  207. ta.remove();
  208. showCopied();
  209. });
  210. console.log('[SP Helper] cURL 已复制 (' + curl.length + ' chars)');
  211. }
  212. function showCopied() {
  213. const btn = document.getElementById('sp-helper-btn');
  214. if (!btn) return;
  215. const prev = btn.innerHTML;
  216. btn.innerHTML = '✅ 已复制!';
  217. btn.style.background = '#107c10';
  218. setTimeout(() => {
  219. btn.innerHTML = prev;
  220. btn.style.background = '#0078d4';
  221. }, 2000);
  222. }
  223. // ========================================
  224. // 6. 启动
  225. // ========================================
  226. // DOM ready 后创建按钮和启动 PerformanceObserver
  227. if (document.readyState === 'loading') {
  228. document.addEventListener('DOMContentLoaded', init);
  229. } else {
  230. init();
  231. }
  232. function init() {
  233. createButton();
  234. startPerfObserver();
  235. // 如果已经捕获到了(fetch/XHR 拦截在 document-start 就生效)
  236. if (capturedManifest) updateButton();
  237. console.log('[SP Helper] 已加载,等待 videomanifest 请求...');
  238. }
  239. })();