sp-stream-helper.user.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. // ==UserScript==
  2. // @name SharePoint Stream Helper
  3. // @namespace https://github.com/sp-stream-helper
  4. // @version 2.0
  5. // @description 一键提交 SharePoint Stream 视频到转录+总结服务
  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. // Settings (stored via GM_setValue)
  17. // ========================================
  18. const DEFAULTS = {
  19. apiEndpoint: '',
  20. apiKey: '',
  21. };
  22. function getSetting(key) {
  23. try { return localStorage.getItem('sp_helper_' + key) || DEFAULTS[key]; }
  24. catch { return DEFAULTS[key]; }
  25. }
  26. function setSetting(key, val) {
  27. try { localStorage.setItem('sp_helper_' + key, val); }
  28. catch {}
  29. }
  30. // ========================================
  31. // Intercept fetch/XHR at document-start
  32. // ========================================
  33. const origFetch = window.fetch;
  34. window.fetch = function (input, init) {
  35. try {
  36. const url = typeof input === 'string' ? input : (input?.url || '');
  37. const method = (init?.method || input?.method || 'GET').toUpperCase();
  38. const headers = extractHeaders(init?.headers || input?.headers);
  39. maybeCapture(method, url, headers);
  40. } catch (e) {}
  41. return origFetch.apply(this, arguments);
  42. };
  43. const origOpen = XMLHttpRequest.prototype.open;
  44. const origSetHeader = XMLHttpRequest.prototype.setRequestHeader;
  45. const origSend = XMLHttpRequest.prototype.send;
  46. XMLHttpRequest.prototype.open = function (method, url) {
  47. this._spH = { method: (method || 'GET').toUpperCase(), url: url || '', headers: {} };
  48. return origOpen.apply(this, arguments);
  49. };
  50. XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
  51. if (this._spH) this._spH.headers[name] = value;
  52. return origSetHeader.apply(this, arguments);
  53. };
  54. XMLHttpRequest.prototype.send = function () {
  55. if (this._spH) maybeCapture(this._spH.method, this._spH.url, this._spH.headers);
  56. return origSend.apply(this, arguments);
  57. };
  58. function extractHeaders(raw) {
  59. const h = {};
  60. if (!raw) return h;
  61. if (raw instanceof Headers) raw.forEach((v, k) => { h[k] = v; });
  62. else if (Array.isArray(raw)) raw.forEach(([k, v]) => { h[k] = v; });
  63. else Object.keys(raw).forEach(k => { h[k] = raw[k]; });
  64. return h;
  65. }
  66. function maybeCapture(method, url, headers) {
  67. if (!url.includes('videomanifest') || method === 'OPTIONS') return;
  68. if (!Object.keys(headers).some(k => k.toLowerCase() === 'x-spopactoken')) return;
  69. capturedManifest = { method, url, headers: { ...headers }, time: new Date().toLocaleTimeString() };
  70. console.log('[SP Helper] ✅ Captured videomanifest');
  71. if (btnReady) updateUI();
  72. }
  73. // ========================================
  74. // Generate cURL
  75. // ========================================
  76. function toCurl(req) {
  77. let pacToken = '', origin = '', referer = '';
  78. for (const [k, v] of Object.entries(req.headers)) {
  79. const l = k.toLowerCase();
  80. if (l === 'x-spopactoken') pacToken = v;
  81. if (l === 'origin') origin = v;
  82. if (l === 'referer') referer = v;
  83. }
  84. const parts = [`curl '${req.url}'`];
  85. parts.push(` -H 'accept: */*'`);
  86. parts.push(` -H 'accept-language: zh-CN,zh;q=0.9'`);
  87. if (origin) parts.push(` -H 'origin: ${origin}'`);
  88. parts.push(` -H 'priority: u=1, i'`);
  89. if (referer) parts.push(` -H 'referer: ${referer}'`);
  90. parts.push(` -H 'sec-ch-ua: "Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"'`);
  91. parts.push(` -H 'sec-ch-ua-mobile: ?0'`);
  92. parts.push(` -H 'sec-ch-ua-platform: "Windows"'`);
  93. parts.push(` -H 'sec-fetch-dest: empty'`);
  94. parts.push(` -H 'sec-fetch-mode: cors'`);
  95. parts.push(` -H 'sec-fetch-site: cross-site'`);
  96. 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'`);
  97. if (pacToken) parts.push(` -H 'x-spopactoken: ${pacToken.replace(/'/g, "'\\''")}'`);
  98. return parts.join(' \\\n');
  99. }
  100. // ========================================
  101. // UI
  102. // ========================================
  103. function createUI() {
  104. const container = document.createElement('div');
  105. container.id = 'sp-helper-ui';
  106. container.innerHTML = `
  107. <style>
  108. #sp-helper-ui { position:fixed; bottom:20px; right:20px; z-index:2147483647; font-family:system-ui,sans-serif; font-size:13px; }
  109. #sp-helper-ui .sp-btn { padding:10px 16px; border-radius:8px; border:none; cursor:pointer; color:#fff; font-size:13px;
  110. box-shadow:0 2px 12px rgba(0,0,0,0.3); transition:all 0.2s; margin-left:8px; }
  111. #sp-helper-ui .sp-btn:hover { filter:brightness(1.1); }
  112. #sp-helper-ui .sp-btn.wait { background:#555; cursor:not-allowed; }
  113. #sp-helper-ui .sp-btn.ready { background:#0078d4; }
  114. #sp-helper-ui .sp-btn.ok { background:#107c10; }
  115. #sp-helper-ui .sp-btn.submit { background:#d83b01; }
  116. #sp-helper-panel { display:none; position:fixed; bottom:70px; right:20px; z-index:2147483647; background:#fff; border-radius:12px;
  117. box-shadow:0 4px 24px rgba(0,0,0,0.2); padding:20px; width:360px; font-family:system-ui,sans-serif; font-size:13px; }
  118. #sp-helper-panel h3 { margin:0 0 12px; font-size:15px; }
  119. #sp-helper-panel label { display:block; margin:8px 0 4px; font-weight:600; }
  120. #sp-helper-panel input, #sp-helper-panel textarea { width:100%; padding:6px 8px; border:1px solid #ccc; border-radius:4px;
  121. font-size:12px; box-sizing:border-box; font-family:Consolas,monospace; }
  122. #sp-helper-panel textarea { height:60px; resize:vertical; }
  123. #sp-helper-panel .sp-row { display:flex; gap:8px; margin-top:12px; }
  124. #sp-helper-panel .sp-row button { flex:1; padding:8px; border:none; border-radius:6px; cursor:pointer; font-size:13px; color:#fff; }
  125. </style>
  126. <div style="display:flex;align-items:center;">
  127. <button id="sp-btn-copy" class="sp-btn wait">⏳ 等待播放...</button>
  128. <button id="sp-btn-submit" class="sp-btn submit" style="display:none">🚀 提交转录</button>
  129. <button id="sp-btn-settings" class="sp-btn" style="background:#666;padding:10px 12px" title="设置">⚙️</button>
  130. </div>
  131. <div id="sp-helper-panel">
  132. <h3>⚙️ SP Stream Helper 设置</h3>
  133. <label>API Endpoint</label>
  134. <input id="sp-set-endpoint" placeholder="https://xxx.execute-api.ap-northeast-1.amazonaws.com">
  135. <label>API Key</label>
  136. <input id="sp-set-apikey" type="password" placeholder="your-api-key">
  137. <div class="sp-row">
  138. <button id="sp-set-save" style="background:#0078d4">💾 保存</button>
  139. <button id="sp-set-close" style="background:#888">关闭</button>
  140. </div>
  141. </div>
  142. <div id="sp-submit-panel" style="display:none;position:fixed;bottom:70px;right:20px;z-index:2147483647;background:#fff;border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,0.2);padding:20px;width:380px;font-family:system-ui,sans-serif;font-size:13px;">
  143. <h3 style="margin:0 0 12px;font-size:15px;">🚀 提交转录任务</h3>
  144. <label style="display:block;margin:8px 0 4px;font-weight:600;">Cookie (从 Cookie-Editor 导出)</label>
  145. <textarea id="sp-submit-cookie" style="width:100%;height:80px;padding:6px 8px;border:1px solid #ccc;border-radius:4px;font-size:11px;box-sizing:border-box;font-family:Consolas,monospace;resize:vertical;" placeholder="粘贴 Cookie-Editor 导出的 JSON"></textarea>
  146. <label style="display:block;margin:8px 0 4px;font-weight:600;">会议语言</label>
  147. <select id="sp-submit-lang" style="width:100%;padding:6px 8px;border:1px solid #ccc;border-radius:4px;font-size:13px;">
  148. <option value="auto">自动识别</option>
  149. <option value="zh-CN" selected>中文 (简体)</option>
  150. <option value="zh-TW">中文 (繁体)</option>
  151. <option value="zh-HK">中文 (粤语)</option>
  152. <option value="en-US">English (US)</option>
  153. </select>
  154. <div style="display:flex;gap:8px;margin-top:12px;">
  155. <button id="sp-submit-go" style="flex:1;padding:8px;border:none;border-radius:6px;cursor:pointer;font-size:13px;color:#fff;background:#d83b01;">🚀 提交</button>
  156. <button id="sp-submit-cancel" style="flex:1;padding:8px;border:none;border-radius:6px;cursor:pointer;font-size:13px;color:#fff;background:#888;">取消</button>
  157. </div>
  158. </div>`;
  159. document.body.appendChild(container);
  160. // Load settings
  161. document.getElementById('sp-set-endpoint').value = getSetting('apiEndpoint');
  162. document.getElementById('sp-set-apikey').value = getSetting('apiKey');
  163. // Events
  164. document.getElementById('sp-btn-copy').addEventListener('click', onCopy);
  165. document.getElementById('sp-btn-submit').addEventListener('click', () => {
  166. document.getElementById('sp-submit-panel').style.display = 'block';
  167. });
  168. document.getElementById('sp-submit-go').addEventListener('click', onSubmit);
  169. document.getElementById('sp-submit-cancel').addEventListener('click', () => {
  170. document.getElementById('sp-submit-panel').style.display = 'none';
  171. });
  172. document.getElementById('sp-btn-settings').addEventListener('click', () => {
  173. const p = document.getElementById('sp-helper-panel');
  174. p.style.display = p.style.display === 'none' ? 'block' : 'none';
  175. });
  176. document.getElementById('sp-set-save').addEventListener('click', () => {
  177. setSetting('apiEndpoint', document.getElementById('sp-set-endpoint').value.trim());
  178. setSetting('apiKey', document.getElementById('sp-set-apikey').value.trim());
  179. document.getElementById('sp-helper-panel').style.display = 'none';
  180. updateUI();
  181. });
  182. document.getElementById('sp-set-close').addEventListener('click', () => {
  183. document.getElementById('sp-helper-panel').style.display = 'none';
  184. });
  185. btnReady = true;
  186. }
  187. function updateUI() {
  188. const btnCopy = document.getElementById('sp-btn-copy');
  189. const btnSubmit = document.getElementById('sp-btn-submit');
  190. if (!btnCopy) return;
  191. if (capturedManifest) {
  192. btnCopy.className = 'sp-btn ready';
  193. btnCopy.innerHTML = `📋 复制 cURL <span style="font-size:11px;opacity:0.6">${capturedManifest.time}</span>`;
  194. if (getSetting('apiEndpoint') && getSetting('apiKey')) {
  195. btnSubmit.style.display = 'inline-block';
  196. }
  197. }
  198. }
  199. function onCopy() {
  200. if (!capturedManifest) return;
  201. const curl = toCurl(capturedManifest);
  202. navigator.clipboard.writeText(curl).catch(() => {
  203. const ta = document.createElement('textarea');
  204. ta.value = curl;
  205. ta.style.cssText = 'position:fixed;left:-9999px';
  206. document.body.appendChild(ta);
  207. ta.select();
  208. document.execCommand('copy');
  209. ta.remove();
  210. });
  211. flashBtn('sp-btn-copy', '✅ 已复制!');
  212. }
  213. async function onSubmit() {
  214. if (!capturedManifest) return;
  215. const endpoint = getSetting('apiEndpoint');
  216. const apiKey = getSetting('apiKey');
  217. const cookie = document.getElementById('sp-submit-cookie').value.trim();
  218. const lang = document.getElementById('sp-submit-lang').value;
  219. if (!endpoint || !apiKey) {
  220. alert('请先在设置中填写 API Endpoint 和 API Key');
  221. return;
  222. }
  223. if (!cookie) {
  224. alert('请粘贴 Cookie-Editor 导出的 Cookie JSON');
  225. return;
  226. }
  227. const btn = document.getElementById('sp-submit-go');
  228. btn.textContent = '⏳ 提交中...';
  229. btn.disabled = true;
  230. try {
  231. const curl = toCurl(capturedManifest);
  232. const resp = await fetch(endpoint.replace(/\/$/, '') + '/submit', {
  233. method: 'POST',
  234. headers: { 'Content-Type': 'application/json' },
  235. body: JSON.stringify({ api_key: apiKey, curl, cookies: cookie, language: lang }),
  236. });
  237. const data = await resp.json();
  238. if (resp.ok) {
  239. document.getElementById('sp-submit-panel').style.display = 'none';
  240. alert(`任务已提交!\nJob ID: ${data.job_id}\n完成后会发送邮件通知。`);
  241. } else {
  242. alert(`提交失败: ${data.error || resp.statusText}`);
  243. }
  244. } catch (e) {
  245. alert(`提交失败: ${e.message}`);
  246. } finally {
  247. btn.textContent = '🚀 提交';
  248. btn.disabled = false;
  249. }
  250. }
  251. function flashBtn(id, text) {
  252. const btn = document.getElementById(id);
  253. if (!btn) return;
  254. const prev = btn.innerHTML;
  255. const prevBg = btn.style.background;
  256. btn.innerHTML = text;
  257. btn.style.background = '#107c10';
  258. setTimeout(() => { btn.innerHTML = prev; btn.style.background = prevBg; }, 2000);
  259. }
  260. // ========================================
  261. // Init
  262. // ========================================
  263. if (document.readyState === 'loading') {
  264. document.addEventListener('DOMContentLoaded', init);
  265. } else {
  266. init();
  267. }
  268. function init() {
  269. createUI();
  270. if (capturedManifest) updateUI();
  271. // PerformanceObserver fallback
  272. try {
  273. new PerformanceObserver((list) => {
  274. for (const e of list.getEntries()) {
  275. if (e.name.includes('videomanifest') && !capturedManifest) {
  276. const pac = getPacToken();
  277. if (pac) {
  278. capturedManifest = { method: 'GET', url: e.name, headers: { 'x-spopactoken': pac }, time: new Date().toLocaleTimeString() };
  279. updateUI();
  280. }
  281. }
  282. }
  283. }).observe({ type: 'resource', buffered: true });
  284. } catch (e) {}
  285. console.log('[SP Helper] v2.0 loaded');
  286. }
  287. function getPacToken() {
  288. try { return window.g_fileInfo?.['.driveAccessCode'] || window.g_fileInfo?.['.driveAccessCodeV21']; } catch { return null; }
  289. }
  290. })();