你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式
你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式
你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式
你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式
你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式
你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式
(我已經安裝了使用者樣式管理器,讓我安裝!)
// ==UserScript==
// @name Teams Chat Exporter
// @namespace http://tampermonkey.net/
// @version 2025-04-23
// @description Export and clean Microsoft Teams chat transcripts and enhance the UI
// @author You
// @match https://teams.microsoft.com/v2/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=microsoft.com
// @grant none
// @license MIT
// ==/UserScript==
(() => {
'use strict';
const KEY_THRESHOLD_MS = 300;
const BANNER_ID = 'teams-chat-banner';
let mutationObserver = null;
let collectedNodes = [];
let resetTimer = null;
let pressCount = 0;
let lastDialogId = 0;
/** Show a fixed red banner at the bottom **/
function showBanner() {
if (document.getElementById(BANNER_ID)) return;
const banner = document.createElement('div');
banner.id = BANNER_ID;
banner.textContent = 'Observing – scroll through chat to gather messages. Press Shift Shift Shift to stop gathering';
Object.assign(banner.style, {
position: 'fixed', bottom: '0', left: '0', width: '100%',
background: 'red', color: 'white', textAlign: 'center',
padding: '8px', fontFamily: 'Arial,sans-serif', zIndex: '9999'
});
document.body.appendChild(banner);
}
/** Remove the observation banner **/
function removeBanner() {
const banner = document.getElementById(BANNER_ID);
if (banner) banner.remove();
}
/** Watch for N rapid key presses **/
const watchKeyCombo = (keyName, callback) => {
document.addEventListener('keydown', event => {
if (!event.key.startsWith(keyName)) return;
pressCount++;
clearTimeout(resetTimer);
resetTimer = setTimeout(() => pressCount = 0, KEY_THRESHOLD_MS);
if (pressCount >= 3) { pressCount = 0; callback(); }
});
};
// Bind triple-Shift and triple-Control
watchKeyCombo('Shift', () => toggleGathering(true));
watchKeyCombo('Control', () => toggleGathering(false));
/** Toggle gathering mode **/
const toggleGathering = observe => {
if (mutationObserver) stopGathering(); else startGathering(observe);
};
/** Start collecting messages **/
function startGathering(observeChanges) {
collectedNodes = [];
gatherCurrentMessages();
if (!observeChanges) { stopGathering(); return; }
showBanner();
const target = document.getElementById('chat-pane-list');
if (!target) { console.error('Target #chat-pane-list not found'); return; }
mutationObserver = new MutationObserver(muts => {
if (muts.some(m => m.type === 'childList' || m.type === 'characterData'))
gatherCurrentMessages();
});
mutationObserver.observe(target, { childList: true, subtree: true, characterData: true });
console.log('Started gathering (observe)', observeChanges);
}
/** Stop collecting and export transcript **/
function stopGathering() {
removeBanner();
if (mutationObserver) { mutationObserver.disconnect(); mutationObserver = null; }
console.log('Stopped gathering, total:', collectedNodes.length);
try {
const nodes = filterAndSort(collectedNodes);
const html = buildTranscript(nodes);
openTranscriptWindow(html);
} catch (e) {
console.error('Error exporting transcript', e);
}
collectedNodes = [];
}
/** Gather current children of chat pane **/
function gatherCurrentMessages() {
const list = document.getElementById('chat-pane-list');
if (list) collectedNodes.push(...Array.from(list.children));
}
/** Filter duplicates, remove GIFs, sort by ID **/
function filterAndSort(nodes) {
const map = new Map();
nodes.forEach(n => {
const msg = n.querySelector('[id^="message-body-"]');
if (msg && !n.querySelector('[aria-label="Animated GIF"]')) {
const id = parseInt(msg.id.replace('message-body-', ''), 10);
if (!map.has(id)) map.set(id, n);
}
});
return Array.from(map.entries())
.sort((a,b) => a[0]-b[0])
.map(entry => entry[1]);
}
/** Replace <img> emojis using alt/text **/
function replaceEmojiImages(node) {
node.querySelectorAll('img[itemtype*="Emoji"]').forEach(img => {
const span = document.createElement('span');
span.innerText = img.alt || '';
img.parentNode.replaceChild(span, img);
});
}
/** Build HTML transcript **/
function buildTranscript(nodes) {
let lastAuthor = '', lastDate = null;
return nodes.map(n => {
replaceEmojiImages(n);
const authorEl = n.querySelector('[data-tid="message-author-name"]');
const timeEl = n.querySelector('[id^="timestamp-"]');
const bodyEl = n.querySelector('[id^="message-body-"] [id^="content-"]');
if (!authorEl || !timeEl || !bodyEl) return '';
const author = authorEl.innerText.trim();
const ts = new Date(timeEl.getAttribute('datetime'));
const tsStr = ts.toLocaleString().replace(/:([0-9]{2})(?= )/, '');
const date = tsStr.split(',')[0];
const newDay = lastDate && ts.getDate() !== lastDate.getDate();
let header = '';
if (author !== lastAuthor) header = `<hr/><b>${author}</b> [${tsStr}]:<br/>`;
else if (newDay) header = `<div style="text-align:center;"><hr/>${date}<hr/></div>`;
lastAuthor = author; lastDate = ts;
// Select all divs with aria-label containing "Mention" in bodyEl
const mentionDivs = bodyEl.querySelectorAll('div[aria-label*="Mention"]');
// Loop through the NodeList and replace each div with its corresponding span
mentionDivs.forEach(div => {
console.log(`Replacing div: `, div); // Debug log for divs being replaced
const span = document.createElement('span');
// Copy over necessary attributes from the div to the span
span.innerHTML = div.innerHTML; // Copy the inner content
span.className = div.className; // Copy the class names
// Insert the span into the div's parent and remove the div
div.parentNode.insertBefore(span, div);
div.parentNode.removeChild(div);
});
// Handle quoted reply replacements
const quotedReplies = bodyEl.querySelectorAll('div[data-track-module-name="messageQuotedReply"]');
quotedReplies.forEach(div => {
const blockquote = document.createElement('blockquote');
blockquote.innerHTML = div.innerHTML; // Copy the inner content
blockquote.className = div.className; // Copy the class names
div.parentNode.insertBefore(blockquote, div);
div.parentNode.removeChild(div);
});
console.log(`Body HTML after replacement is: `, bodyEl.innerHTML); // Debug log for updated body HTML
// Generate the updated body HTML
const updatedBodyHtml = bodyEl.innerHTML;
return `<div class="message">${header}<section>${updatedBodyHtml}</section></div>`;
}).join('');
}
/** Open transcript in new window **/
function openTranscriptWindow(content) {
lastDialogId++;
const dialog = document.createElement('dialog');
dialog.style.width = '80vw';
dialog.style.height = '80vh';
dialog.style.overflow = 'auto';
dialog.style.padding = '20px';
// Create content for the dialog
const dialogContent = `
<style>
#transcript {
font-family: Arial, sans-serif;
margin: 2em;
}
section {
padding-left: 1em;
border-left: 1px solid #ccc;
}
hr {
border: none;
border-top: 1px solid #ccc;
margin: 1em 0;
}
.closeDialog {position: absolute; top:-50px; left:50%; transform:translateX(-50%);}
div[aria-label*="Mention"] {
display: inline;
font-weight: bold;
}
</style>
<div id='transcript'>
${content}
<button class='closeDialog' id="closeDialog-${lastDialogId}">Close</button>
</div>
`;
// Set the inner HTML of the dialog
dialog.innerHTML = dialogContent;
// Append dialog to the body
document.body.appendChild(dialog);
// Open the dialog
dialog.showModal();
// Add an event listener to the close button
document.getElementById(`closeDialog-${lastDialogId}`).addEventListener('click', () => {
dialog.close();
});
}
/** Live-page formatting: wrap galleries, emojis, code blocks **/
function formatMessages() {
const sections = document.querySelectorAll('.message section');
sections.forEach(section => {
// link gallery images
section.querySelectorAll('img[data-gallery-src]').forEach(img => {
const link = document.createElement('a');
link.href = img.getAttribute('data-gallery-src'); link.target = '_blank';
img.replaceWith(link); link.appendChild(img);
});
// convert emojis
section.querySelectorAll('img[alt]').forEach(img => {
const alt = img.alt.trim();
if (/^(?:\p{Emoji}|[\u203C-\u3299\uD83C\uD000-\uD83F\uDC00-\uDFFF])+/u.test(alt)) {
const span = document.createElement('span'); span.innerText = alt;
img.replaceWith(span);
}
});
// structure first span
const firstSpan = section.querySelector('div > div > div div:first-child span:first-child');
if (firstSpan) {
const strong = document.createElement('strong'); strong.textContent = firstSpan.textContent;
firstSpan.textContent = ''; firstSpan.appendChild(strong);
}
// add nbsp and breaks
section.querySelectorAll('div > div > div div:first-child span').forEach(span => span.innerHTML += ' ');
const firstDiv = section.querySelector('div > div > div div:first-child');
if (firstDiv) firstDiv.innerHTML += '<br><br>';
// replace parent div with blockquote
const parentDiv = section.querySelector('div > div > div');
if (parentDiv) {
const bq = document.createElement('blockquote');
while (parentDiv.firstChild) bq.appendChild(parentDiv.firstChild);
parentDiv.replaceWith(bq);
}
});
}
formatMessages();
/** Convert all page images to base64 **/
async function replaceImagesWithBase64() {
const imgs = document.querySelectorAll('img');
for (const img of imgs) {
try {
const res = await fetch(img.src);
const blob = await res.blob();
const reader = new FileReader();
reader.onloadend = () => {
const newImg = document.createElement('img');
newImg.src = reader.result; newImg.alt = img.alt;
img.replaceWith(newImg);
};
reader.readAsDataURL(blob);
} catch (e) {
console.error('Image base64 error', e);
}
}
}
replaceImagesWithBase64();
})();