Teams Chat Exporter

Export and clean Microsoft Teams chat transcripts and enhance the UI

  1. // ==UserScript==
  2. // @name Teams Chat Exporter
  3. // @namespace http://tampermonkey.net/
  4. // @version 2025-04-23
  5. // @description Export and clean Microsoft Teams chat transcripts and enhance the UI
  6. // @author You
  7. // @match https://teams.microsoft.com/v2/*
  8. // @icon https://www.google.com/s2/favicons?sz=64&domain=microsoft.com
  9. // @grant none
  10. // @license MIT
  11. // ==/UserScript==
  12.  
  13. (() => {
  14. 'use strict';
  15.  
  16. const KEY_THRESHOLD_MS = 300;
  17. const BANNER_ID = 'teams-chat-banner';
  18. let mutationObserver = null;
  19. let collectedNodes = [];
  20. let resetTimer = null;
  21. let pressCount = 0;
  22. let lastDialogId = 0;
  23.  
  24. /** Show a fixed red banner at the bottom **/
  25. function showBanner() {
  26. if (document.getElementById(BANNER_ID)) return;
  27. const banner = document.createElement('div');
  28. banner.id = BANNER_ID;
  29. banner.textContent = 'Observing – scroll through chat to gather messages. Press Shift Shift Shift to stop gathering';
  30. Object.assign(banner.style, {
  31. position: 'fixed', bottom: '0', left: '0', width: '100%',
  32. background: 'red', color: 'white', textAlign: 'center',
  33. padding: '8px', fontFamily: 'Arial,sans-serif', zIndex: '9999'
  34. });
  35. document.body.appendChild(banner);
  36. }
  37.  
  38. /** Remove the observation banner **/
  39. function removeBanner() {
  40. const banner = document.getElementById(BANNER_ID);
  41. if (banner) banner.remove();
  42. }
  43.  
  44. /** Watch for N rapid key presses **/
  45. const watchKeyCombo = (keyName, callback) => {
  46. document.addEventListener('keydown', event => {
  47. if (!event.key.startsWith(keyName)) return;
  48. pressCount++;
  49. clearTimeout(resetTimer);
  50. resetTimer = setTimeout(() => pressCount = 0, KEY_THRESHOLD_MS);
  51. if (pressCount >= 3) { pressCount = 0; callback(); }
  52. });
  53. };
  54.  
  55. // Bind triple-Shift and triple-Control
  56. watchKeyCombo('Shift', () => toggleGathering(true));
  57. watchKeyCombo('Control', () => toggleGathering(false));
  58.  
  59. /** Toggle gathering mode **/
  60. const toggleGathering = observe => {
  61. if (mutationObserver) stopGathering(); else startGathering(observe);
  62. };
  63.  
  64. /** Start collecting messages **/
  65. function startGathering(observeChanges) {
  66. collectedNodes = [];
  67. gatherCurrentMessages();
  68. if (!observeChanges) { stopGathering(); return; }
  69. showBanner();
  70. const target = document.getElementById('chat-pane-list');
  71. if (!target) { console.error('Target #chat-pane-list not found'); return; }
  72. mutationObserver = new MutationObserver(muts => {
  73. if (muts.some(m => m.type === 'childList' || m.type === 'characterData'))
  74. gatherCurrentMessages();
  75. });
  76. mutationObserver.observe(target, { childList: true, subtree: true, characterData: true });
  77. console.log('Started gathering (observe)', observeChanges);
  78. }
  79.  
  80. /** Stop collecting and export transcript **/
  81. function stopGathering() {
  82. removeBanner();
  83. if (mutationObserver) { mutationObserver.disconnect(); mutationObserver = null; }
  84. console.log('Stopped gathering, total:', collectedNodes.length);
  85. try {
  86. const nodes = filterAndSort(collectedNodes);
  87. const html = buildTranscript(nodes);
  88. openTranscriptWindow(html);
  89. } catch (e) {
  90. console.error('Error exporting transcript', e);
  91. }
  92. collectedNodes = [];
  93. }
  94.  
  95. /** Gather current children of chat pane **/
  96. function gatherCurrentMessages() {
  97. const list = document.getElementById('chat-pane-list');
  98. if (list) collectedNodes.push(...Array.from(list.children));
  99. }
  100.  
  101. /** Filter duplicates, remove GIFs, sort by ID **/
  102. function filterAndSort(nodes) {
  103. const map = new Map();
  104. nodes.forEach(n => {
  105. const msg = n.querySelector('[id^="message-body-"]');
  106. if (msg && !n.querySelector('[aria-label="Animated GIF"]')) {
  107. const id = parseInt(msg.id.replace('message-body-', ''), 10);
  108. if (!map.has(id)) map.set(id, n);
  109. }
  110. });
  111. return Array.from(map.entries())
  112. .sort((a,b) => a[0]-b[0])
  113. .map(entry => entry[1]);
  114. }
  115.  
  116. /** Replace <img> emojis using alt/text **/
  117. function replaceEmojiImages(node) {
  118. node.querySelectorAll('img[itemtype*="Emoji"]').forEach(img => {
  119. const span = document.createElement('span');
  120. span.innerText = img.alt || '';
  121. img.parentNode.replaceChild(span, img);
  122. });
  123. }
  124.  
  125. /** Build HTML transcript **/
  126. function buildTranscript(nodes) {
  127. let lastAuthor = '', lastDate = null;
  128. return nodes.map(n => {
  129. replaceEmojiImages(n);
  130. const authorEl = n.querySelector('[data-tid="message-author-name"]');
  131. const timeEl = n.querySelector('[id^="timestamp-"]');
  132. const bodyEl = n.querySelector('[id^="message-body-"] [id^="content-"]');
  133. if (!authorEl || !timeEl || !bodyEl) return '';
  134. const author = authorEl.innerText.trim();
  135. const ts = new Date(timeEl.getAttribute('datetime'));
  136. const tsStr = ts.toLocaleString().replace(/:([0-9]{2})(?= )/, '');
  137. const date = tsStr.split(',')[0];
  138. const newDay = lastDate && ts.getDate() !== lastDate.getDate();
  139. let header = '';
  140. if (author !== lastAuthor) header = `<hr/><b>${author}</b> [${tsStr}]:<br/>`;
  141. else if (newDay) header = `<div style="text-align:center;"><hr/>${date}<hr/></div>`;
  142. lastAuthor = author; lastDate = ts;
  143.  
  144. // Select all divs with aria-label containing "Mention" in bodyEl
  145. const mentionDivs = bodyEl.querySelectorAll('div[aria-label*="Mention"]');
  146.  
  147. // Loop through the NodeList and replace each div with its corresponding span
  148. mentionDivs.forEach(div => {
  149. console.log(`Replacing div: `, div); // Debug log for divs being replaced
  150. const span = document.createElement('span');
  151.  
  152. // Copy over necessary attributes from the div to the span
  153. span.innerHTML = div.innerHTML; // Copy the inner content
  154. span.className = div.className; // Copy the class names
  155.  
  156. // Insert the span into the div's parent and remove the div
  157. div.parentNode.insertBefore(span, div);
  158. div.parentNode.removeChild(div);
  159. });
  160.  
  161. // Handle quoted reply replacements
  162. const quotedReplies = bodyEl.querySelectorAll('div[data-track-module-name="messageQuotedReply"]');
  163. quotedReplies.forEach(div => {
  164. const blockquote = document.createElement('blockquote');
  165. blockquote.innerHTML = div.innerHTML; // Copy the inner content
  166. blockquote.className = div.className; // Copy the class names
  167. div.parentNode.insertBefore(blockquote, div);
  168. div.parentNode.removeChild(div);
  169. });
  170.  
  171.  
  172. console.log(`Body HTML after replacement is: `, bodyEl.innerHTML); // Debug log for updated body HTML
  173.  
  174. // Generate the updated body HTML
  175. const updatedBodyHtml = bodyEl.innerHTML;
  176.  
  177. return `<div class="message">${header}<section>${updatedBodyHtml}</section></div>`;
  178. }).join('');
  179. }
  180.  
  181. /** Open transcript in new window **/
  182. function openTranscriptWindow(content) {
  183. lastDialogId++;
  184. const dialog = document.createElement('dialog');
  185. dialog.style.width = '80vw';
  186. dialog.style.height = '80vh';
  187. dialog.style.overflow = 'auto';
  188. dialog.style.padding = '20px';
  189.  
  190. // Create content for the dialog
  191. const dialogContent = `
  192. <style>
  193. #transcript {
  194. font-family: Arial, sans-serif;
  195. margin: 2em;
  196. }
  197. section {
  198. padding-left: 1em;
  199. border-left: 1px solid #ccc;
  200. }
  201. hr {
  202. border: none;
  203. border-top: 1px solid #ccc;
  204. margin: 1em 0;
  205. }
  206. .closeDialog {position: absolute; top:-50px; left:50%; transform:translateX(-50%);}
  207.  
  208. div[aria-label*="Mention"] {
  209. display: inline;
  210. font-weight: bold;
  211. }
  212. </style>
  213. <div id='transcript'>
  214. ${content}
  215. <button class='closeDialog' id="closeDialog-${lastDialogId}">Close</button>
  216. </div>
  217. `;
  218.  
  219. // Set the inner HTML of the dialog
  220. dialog.innerHTML = dialogContent;
  221.  
  222. // Append dialog to the body
  223. document.body.appendChild(dialog);
  224.  
  225. // Open the dialog
  226. dialog.showModal();
  227.  
  228. // Add an event listener to the close button
  229. document.getElementById(`closeDialog-${lastDialogId}`).addEventListener('click', () => {
  230. dialog.close();
  231. });
  232. }
  233.  
  234. /** Live-page formatting: wrap galleries, emojis, code blocks **/
  235. function formatMessages() {
  236. const sections = document.querySelectorAll('.message section');
  237. sections.forEach(section => {
  238. // link gallery images
  239. section.querySelectorAll('img[data-gallery-src]').forEach(img => {
  240. const link = document.createElement('a');
  241. link.href = img.getAttribute('data-gallery-src'); link.target = '_blank';
  242. img.replaceWith(link); link.appendChild(img);
  243. });
  244. // convert emojis
  245. section.querySelectorAll('img[alt]').forEach(img => {
  246. const alt = img.alt.trim();
  247. if (/^(?:\p{Emoji}|[\u203C-\u3299\uD83C\uD000-\uD83F\uDC00-\uDFFF])+/u.test(alt)) {
  248. const span = document.createElement('span'); span.innerText = alt;
  249. img.replaceWith(span);
  250. }
  251. });
  252. // structure first span
  253. const firstSpan = section.querySelector('div > div > div div:first-child span:first-child');
  254. if (firstSpan) {
  255. const strong = document.createElement('strong'); strong.textContent = firstSpan.textContent;
  256. firstSpan.textContent = ''; firstSpan.appendChild(strong);
  257. }
  258. // add nbsp and breaks
  259. section.querySelectorAll('div > div > div div:first-child span').forEach(span => span.innerHTML += '&nbsp;&nbsp;');
  260. const firstDiv = section.querySelector('div > div > div div:first-child');
  261. if (firstDiv) firstDiv.innerHTML += '<br><br>';
  262. // replace parent div with blockquote
  263. const parentDiv = section.querySelector('div > div > div');
  264. if (parentDiv) {
  265. const bq = document.createElement('blockquote');
  266. while (parentDiv.firstChild) bq.appendChild(parentDiv.firstChild);
  267. parentDiv.replaceWith(bq);
  268. }
  269. });
  270. }
  271. formatMessages();
  272.  
  273. /** Convert all page images to base64 **/
  274. async function replaceImagesWithBase64() {
  275. const imgs = document.querySelectorAll('img');
  276. for (const img of imgs) {
  277. try {
  278. const res = await fetch(img.src);
  279. const blob = await res.blob();
  280. const reader = new FileReader();
  281. reader.onloadend = () => {
  282. const newImg = document.createElement('img');
  283. newImg.src = reader.result; newImg.alt = img.alt;
  284. img.replaceWith(newImg);
  285. };
  286. reader.readAsDataURL(blob);
  287. } catch (e) {
  288. console.error('Image base64 error', e);
  289. }
  290. }
  291. }
  292. replaceImagesWithBase64();
  293. })();

QingJ © 2025

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