├── .gitignore ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── demo.gif └── src ├── background.js ├── content.js ├── icon.png ├── image.png ├── manifest.json ├── options.js ├── popup.html └── popup.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | macos-rw.dmg 3 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "chrome", 6 | "request": "launch", 7 | "name": "Launch Chrome", 8 | "url": "https://example.com", 9 | "runtimeArgs": [ 10 | "--load-extension=${workspaceFolder}/src" 11 | ] 12 | }, 13 | { 14 | "type": "firefox", 15 | "request": "launch", 16 | "name": "Launch Firefox", 17 | "url": "https://example.com", 18 | "addonPath": "${workspaceFolder}/src" 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jacopo Jannone 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebXDownloader 2 | 3 | This is a browser extension that helps downloading Webex meeting recordings. It adds a button to the video playback controls that enables downloading of the recording in mp4 format. It also provides the direct URL to the HSL stream and allows the chat transcript to be saved in JSON or plain text format. 4 | 5 |  6 | 7 | ## Installation 8 | 9 | ### Firefox (recommended) 10 | 11 | Download the `.xpi` file from the [latest release](https://github.com/jacopo-j/WebXDownloader/releases) and drag it to any Firefox window to install. 12 | 13 | ### Google Chrome 14 | 15 | * Download the `.zip` file from the [latest release](https://github.com/jacopo-j/WebXDownloader/releases) 16 | * Extract the zip file 17 | * Browse to `chrome://extensions` 18 | * Turn on "Developer mode" on the top right 19 | * Click "Load unpacked extension..." on the top left 20 | * Select the folder named `src` from the folder to which your zip file was extracted. 21 | 22 | ### Safari (experimental) 23 | 24 | 1. Download the `.dmg` file from the [latest release](https://github.com/jacopo-j/WebXDownloader/releases) 25 | 2. Mount the disk image by double clicking it 26 | 3. Drag the WebXDownloader app to your Applications folder 27 | 4. Open the app once (right click > open) 28 | 5. Open Safari > Preferences > Extensions and enable WebXDownloader 29 | 30 | ## Usage 31 | 32 | Just browse to the meeting recording page and you will find a download button on the right side of the playback control bar. If you want to copy the HLS stream URL or download the chat transcript, launch the extension from your browser. 33 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacopo-j/WebXDownloader/2d22adfe65a2b44cedb19bce0d4f251687de1445/demo.gif -------------------------------------------------------------------------------- /src/background.js: -------------------------------------------------------------------------------- 1 | // Listener used in the background to execute 2 | // commands passed by parameter from the extension 3 | chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { 4 | if (request.fetchJson) { 5 | // Set the header used to fetch the JSON with the 6 | // parameters passed by the main "process" 7 | const headers = { headers: { "Accept": "application/json, text/plain, */*" } }; 8 | if (request.password) headers.headers.accessPwd = request.password; 9 | else headers.headers.appFrom = "pb"; 10 | 11 | fetch(request.fetchJson, headers) 12 | .then(response => response.json()) 13 | .then(response => sendResponse(response)) 14 | .catch(_exception => sendResponse(null)); 15 | return true; 16 | } 17 | 18 | if (request.fetchText) { 19 | fetch(request.fetchText) 20 | .then(response => response.text()) 21 | .then(response => sendResponse(response)); 22 | return true; 23 | } 24 | 25 | if (request.safariOpenUrl) { 26 | chrome.tabs.create({url: request.safariOpenUrl}); 27 | } 28 | 29 | if (request.downloadURL && request.savepath) { 30 | chrome.downloads.download({ 31 | url: request.downloadURL, 32 | filename: request.savepath 33 | }); 34 | } 35 | } 36 | ); 37 | 38 | function reqWatcher(details) { 39 | // Find the detail containing the access password 40 | const result = details.requestHeaders.filter((e) => e.name === "accessPwd"); 41 | 42 | if (result.length > 0) { 43 | // The password exists 44 | const password = result[0]; 45 | 46 | // Send the password to the the current tab 47 | const callback = (tabs) => chrome.tabs.sendMessage(tabs[0].id, { recPassword: password.value }); 48 | chrome.tabs.query({ 49 | active: true, 50 | currentWindow: true 51 | }, callback); 52 | } 53 | } 54 | 55 | chrome.webRequest.onSendHeaders.addListener( 56 | reqWatcher, 57 | {urls: ["*://*.webex.com/webappng/api/v1/recordings/*"]}, 58 | ["requestHeaders"] 59 | ); 60 | -------------------------------------------------------------------------------- /src/content.js: -------------------------------------------------------------------------------- 1 | const REGEX = /^https?:\/\/(.+?)\.webex\.com\/(?:recordingservice|webappng)\/sites\/([^\/]+)\/.*?([a-f0-9]{32})[^\?]*(\?.*)?/g; 2 | const MATCH = REGEX.exec(location.href); 3 | const SUBDOMAIN = MATCH[1]; 4 | const SITENAME = MATCH[2]; 5 | const RECORDING_ID = MATCH[3]; 6 | const AUTH_PARAMS = MATCH[4]; 7 | var API_URL = `https://${SUBDOMAIN}.webex.com/webappng/api/v1/recordings/${RECORDING_ID}/stream`; 8 | var PASSWORD; 9 | var API_RESPONSE = -1; 10 | 11 | if (AUTH_PARAMS) API_URL += AUTH_PARAMS; 12 | 13 | /** 14 | * Create the download button to add to the Webex video page. 15 | * @param {string} downloadURL URL of the video to download. 16 | * @param {string} savepath Path where save the recording. 17 | */ 18 | function createDownloadButton(downloadURL, savepath) { 19 | // Create the button 20 | const i = document.createElement("i"); 21 | i.setAttribute("title", "Download"); 22 | i.setAttribute("tabindex", "0") 23 | i.setAttribute("role", "button"); 24 | i.setAttribute("id", "playerDownload"); 25 | i.setAttribute("aria-label", `Download recording: ${savepath}`); 26 | i.classList.add("icon-download", "recordingDownload"); 27 | 28 | // Add the onClick and onKeyPress events 29 | const downloadMessage = { 30 | downloadURL: downloadURL, 31 | savepath: savepath 32 | }; 33 | i.addEventListener("click", () => chrome.runtime.sendMessage(downloadMessage)); 34 | i.addEventListener("keypress", () => chrome.runtime.sendMessage(downloadMessage)); 35 | 36 | return i; 37 | } 38 | 39 | /** 40 | * Process a JSON-formatted response obtained from a WebEx page. 41 | */ 42 | function parseParametersFromResponse(response) { 43 | // Alias used to centralize response values 44 | const streamOption = response["mp4StreamOption"]; 45 | 46 | // get the new endpoint that can be (ab)used to download the video 47 | // It's very fast if Multi-threading download is supported (for example with aria2c downloader), but for now does not work on browsers 48 | // On Chrome it's possible to enable it going to chrome://flags/#enable-parallel-downloading 49 | const fallbackPlaySrc = response['fallbackPlaySrc'] 50 | 51 | // Get the data we need to get the video stream 52 | const host = streamOption["host"]; 53 | const recordingDir = streamOption["recordingDir"]; 54 | const timestamp = streamOption["timestamp"]; 55 | const token = streamOption["token"]; 56 | const xmlName = streamOption["xmlName"]; 57 | const playbackOption = streamOption["playbackOption"]; 58 | 59 | // Get the name of the recording 60 | const recordName = response["recordName"]; 61 | 62 | return { 63 | host, 64 | recordingDir, 65 | timestamp, 66 | token, 67 | xmlName, 68 | playbackOption, 69 | recordName, 70 | fallbackPlaySrc 71 | } 72 | } 73 | 74 | function sanitizeFilename(filename) { 75 | const allowedChars = /[^\w\s\d\-_~,;\[\]\(\).]/g; 76 | return filename.replaceAll(allowedChars, "_"); 77 | } 78 | 79 | /** 80 | * Callback used by a MutationObserver object in a 81 | * WebEx page containing a registration to download. 82 | */ 83 | function mutationCallback(_mutationArray, observer) { 84 | // Check if the change is the one we want 85 | // Otherwise it returns (fast fail) 86 | const playButtons = document.getElementsByClassName("recordingTitle"); // Recording title 87 | if (!playButtons.length) return; 88 | 89 | // Disconnect this observer to avoid 90 | // triggering the DOM change detection event 91 | observer.disconnect(); 92 | 93 | chrome.runtime.sendMessage({ 94 | fetchJson: API_URL, 95 | password: PASSWORD 96 | }, 97 | (response) => { 98 | // Save the response from the page 99 | API_RESPONSE = response; 100 | 101 | // Get the useful parameters from the received response 102 | const params = parseParametersFromResponse(response); 103 | 104 | // Add the download button 105 | addDownloadButtonToPage(params); 106 | } 107 | ) 108 | } 109 | 110 | /** 111 | * Add the download button to the video viewer bar. 112 | * @param {Object} params 113 | */ 114 | function addDownloadButtonToPage(params) { 115 | // Do not add the button if already present 116 | const downloadButtons = document.getElementsByClassName("icon-download") 117 | if (downloadButtons.length) return; 118 | 119 | 120 | // Set the recording name as the save name 121 | const savename = `${sanitizeFilename(params.recordName)}.mp4`; 122 | 123 | // Compose the download link of the video 124 | const downloadURL = params.fallbackPlaySrc; 125 | 126 | // Create the download button 127 | const downloadButton = createDownloadButton(downloadURL.toString(), savename); 128 | 129 | // Get the buttons on the viewer bar and add the download button 130 | const titleDivs = document.getElementsByClassName('recordingHeader'); 131 | titleDivs[0].appendChild(downloadButton); 132 | }; 133 | 134 | // Add a listener used to receive the password for the WebEx account 135 | chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { 136 | if (request.recPassword) PASSWORD = request.recPassword; 137 | if (request.apiResponse) sendResponse(API_RESPONSE); 138 | }); 139 | 140 | // Create an observer for the DOM 141 | const observer = new MutationObserver(mutationCallback); 142 | 143 | observer.observe(document.body, { 144 | childList: true, 145 | subtree: true 146 | }); 147 | -------------------------------------------------------------------------------- /src/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacopo-j/WebXDownloader/2d22adfe65a2b44cedb19bce0d4f251687de1445/src/icon.png -------------------------------------------------------------------------------- /src/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacopo-j/WebXDownloader/2d22adfe65a2b44cedb19bce0d4f251687de1445/src/image.png -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Enables downloading of Webex meeting recordings", 3 | "version": "1.3.3", 4 | "permissions": [ 5 | "downloads", 6 | "storage", 7 | "tabs", 8 | "webRequest", 9 | "https://*.webex.com/*", 10 | "https://api.github.com/repos/jacopo-j/webxdownloader/releases/latest" 11 | ], 12 | "homepage_url": "https://github.com/jacopo-j/WebXDownloader", 13 | "background": { 14 | "scripts": [ 15 | "background.js" 16 | ] 17 | }, 18 | "content_scripts": [ 19 | { 20 | "matches": [ 21 | "*://*.webex.com/*" 22 | ], 23 | "js": [ 24 | "content.js" 25 | ], 26 | "run_at": "document_end" 27 | } 28 | ], 29 | "browser_action": { 30 | "default_icon": "icon.png", 31 | "default_title": "WebXDownloader", 32 | "default_popup": "popup.html" 33 | }, 34 | "manifest_version": 2, 35 | "name": "WebXDownloader", 36 | "icons": { 37 | "128": "icon.png" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/options.js: -------------------------------------------------------------------------------- 1 | function saveOptions() { 2 | chrome.storage.local.set({ 3 | hideprotip: document.getElementById("hide-protip").checked 4 | }); 5 | } 6 | 7 | function restoreOptions() { 8 | 9 | function setCurrentChoice(result) { 10 | if (! result.hideprotip && document.getElementById("errpage").style.display === "none") { 11 | document.getElementById("protip").style.display = "block"; 12 | } 13 | } 14 | 15 | chrome.storage.local.get("hideprotip", setCurrentChoice); 16 | } 17 | 18 | document.addEventListener("DOMContentLoaded", restoreOptions); 19 | document.getElementById("hide-protip").addEventListener("click", saveOptions); 20 | -------------------------------------------------------------------------------- /src/popup.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 79 | 80 | 81 | 84 | 89 | 103 |