元素
statusDiv.id = 'extract-status-div'; // 设置 ID
statusDiv.style.cssText = `
position: fixed; bottom: 65px; right: 20px; z-index: 9998; /* 定位在右下角,比按钮稍高一点 */
padding: 5px 10px; background-color: rgba(0,0,0,0.7); color: white; /* 半透明黑色背景,白色文字,确保在各种背景下都可见 */
font-size: 12px; /* 使用较小的字体 */
border-radius: 3px; /* 轻微的圆角 */
display: none; /* 初始状态不显示 */
`;
document.body.appendChild(statusDiv);
// --- 使用 Tampermonkey 的 GM_addStyle 函数添加全局 CSS 规则 ---
// 这对于定义伪类(如 :disabled)或需要根据状态动态添加/移除的类(如 .success, .error)的样式特别有用。
GM_addStyle(`
/* 定义按钮在被禁用 (:disabled) 状态下的通用样式 */
#capture-chat-button:disabled, #stop-scrolling-button:disabled {
opacity: 0.6; /* 使按钮看起来半透明,表示不可用 */
cursor: not-allowed; /* 鼠标悬停时显示“禁止”图标 */
background-color: #aaa !important; /* 背景色变为灰色,使用 !important 确保能覆盖元素上可能存在的其他背景色设置 */
}
/* 定义“开始/导出”按钮在成功状态下的样式 (通过脚本添加 .success 类来应用) */
#capture-chat-button.success {
background-color: #1e8e3e !important; /* Google 绿色 */
}
/* 定义“开始/导出”按钮在错误状态下的样式 (通过脚本添加 .error 类来应用) */
#capture-chat-button.error {
background-color: #d93025 !important; /* Google 红色 */
}
`);
console.log("UI 元素创建完成。"); // 日志记录 UI 创建过程结束
}
/**
* 更新状态显示 DIV 中的文本内容,并在浏览器开发者控制台打印相同的信息。
* 这是向用户反馈脚本当前进度的主要方式。
* @param {string} message - 要显示的状态信息。如果传入一个空字符串,则会隐藏状态 DIV。
*/
function updateStatus(message) {
if (statusDiv) { // 确保 statusDiv 元素已经被创建并且可以访问
statusDiv.textContent = message; // 设置 DIV 的文本内容
// 使用三元运算符根据 message 是否为空字符串来设置 display 样式
statusDiv.style.display = message ? 'block' : 'none'; // 如果 message 非空,设置为 'block'(可见),否则设置为 'none'(隐藏)
}
// 无论 UI 是否更新成功,总是在浏览器的开发者控制台打印状态信息。
// 这对于调试脚本非常有帮助,即使 UI 元素出现问题,也能看到脚本的运行状态。
console.log(`[Status] ${message}`);
}
// --- 核心业务逻辑 ---
/**
* 在滚动过程中被反复调用的核心函数,用于增量地提取当前可见的聊天回合数据。
* 它会查找页面上的 `ms-chat-turn` 元素,识别用户或模型回合,
* 尝试提取文本内容(用户输入、思维链、AI 回答),并将结果存储或更新到 `collectedData` Map 中。
* **关键改进**:
* 1. 使用 DOM 元素本身作为 Map 的 Key,确保唯一性。
* 2. 强制为每个识别出的回合在 Map 中创建记录,即使初次提取内容失败。
* 3. 使用 `textContent` 提取思维链,解决隐藏内容问题。
* 4. 优化 AI 回答提取,优先使用内部 `ms-cmark-node`,并改进后备逻辑。
* @returns {boolean} - 返回 `true` 如果本次调用找到了新的回合或更新了已有回合的数据,否则返回 `false`。
*/
function extractDataIncremental_AiStudio() {
let newlyFoundCount = 0; // 计数器:本次调用新添加到 Map 中的回合数量
let dataUpdatedInExistingTurn = false; // 标志:本次调用是否更新了 Map 中已存在的回合数据
console.log("--- 开始增量提取 ---"); // 日志:标记提取过程开始
// 1. 获取当前页面上所有代表聊天回合的 `ms-chat-turn` 元素
const currentTurns = document.querySelectorAll('ms-chat-turn');
console.log(`发现 ${currentTurns.length} 个 ms-chat-turn 元素`); // 记录找到的回合总数
// 2. 遍历找到的每一个 `ms-chat-turn` 元素
currentTurns.forEach((turn, index) => {
// 3. *** 使用 `ms-chat-turn` DOM 节点本身作为 Map 的唯一 Key ***
// 这是解决之前版本中 Key 冲突问题的关键。DOM 节点引用是唯一的。
const turnKey = turn;
// 打印日志,换行增加可读性,并显示正在处理哪个回合(基于其在查询结果中的索引)
console.log(`\n处理回合 ${index + 1} (Key: DOM Node)`);
// 4. 查找当前回合内部是用户容器还是模型容器
const turnContainer = turn.querySelector('.chat-turn-container.user, .chat-turn-container.model');
if (!turnContainer) { // 如果连基本的容器都找不到,说明这个回合结构异常,记录警告并跳过
console.warn(` 回合 ${index + 1}: 内部找不到 .user 或 .model 容器,跳过`);
return; // 跳到 forEach 的下一次迭代
}
// 5. *** 强制记录回合 ***
// 检查这个 `turnKey` (DOM 节点) 是否已经存在于 `collectedData` Map 中
let isNewTurn = !collectedData.has(turnKey);
// 从 Map 中获取该回合已存储的数据(如果存在),或者创建一个新的空对象
let extractedInfo = collectedData.get(turnKey) || {
domOrder: index, // 记录它在本次查询中的原始顺序,用于最后的排序
type: 'unknown', // 初始类型未知
userText: null,
thoughtText: null,
responseText: null
};
// **关键点**:无论后续内容提取是否成功,只要这是第一次遇到这个回合 (`isNewTurn` 为 true),
// 就先在 Map 中为它创建一个条目(使用上面的 `extractedInfo` 对象)。
// 这确保了即使某个回合的内容在第一次看到时未能完全提取,这个回合本身也不会丢失,
// 后续滚动再次看到它时,可以补充提取内容。
if (isNewTurn) {
collectedData.set(turnKey, extractedInfo); // 先存入 Map
newlyFoundCount++; // 增加新回合计数
console.log(` 回合 ${index + 1}: 首次遇到,已在 Map 中创建/获取记录`);
}
let dataWasUpdatedThisTime = false; // 重置当前回合的数据更新标志
// 6. 根据容器类型(用户或模型)进行内容提取
if (turnContainer.classList.contains('user')) {
// --- 处理用户回合 ---
console.log(` 回合 ${index + 1}: 检测为 User`);
// 仅当类型未知时,将其设置为 'user'
if (extractedInfo.type === 'unknown') extractedInfo.type = 'user';
// 仅当用户文本尚未被记录时 (`!extractedInfo.userText`) 才尝试提取
if (!extractedInfo.userText) {
// 尝试使用之前的精确选择器
let userNode = turn.querySelector('.turn-content ms-cmark-node.user-chunk');
let userText = userNode ? userNode.innerText.trim() : null; // 获取可见文本
console.log(` 尝试查找 .user-chunk: ${userNode ? '找到' : '未找到'}. Text: "${userText}"`);
// 如果精确选择器失败或未提取到文本,尝试更通用的方法:获取整个 `.turn-content` 的文本
// *遇到的问题*:有时用户输入的结构可能变化,精确选择器失效。
// *解决方案*:增加一个更通用的后备选择器。
if (!userText) {
const turnContent = turn.querySelector('.turn-content');
if (turnContent) {
userText = turnContent.innerText.trim(); // 获取可见文本
console.log(` 尝试 .turn-content innerText: "${userText}"`);
}
}
// 如果最终提取到了非空文本
if (userText) {
extractedInfo.userText = userText; // 存储到数据对象中
console.log(` 成功提取用户文本: "${userText}"`);
dataWasUpdatedThisTime = true; // 标记本回合数据有更新
} else {
console.log(` 未能提取到用户文本`); // 记录提取失败
}
} else {
// 如果用户文本已存在,则打印日志说明,不再重复提取
console.log(` 用户文本已存在: "${extractedInfo.userText}"`);
}
} else if (turnContainer.classList.contains('model')) {
// --- 处理模型回合 ---
console.log(` 回合 ${index + 1}: 检测为 Model`);
// 仅当类型未知时,先标记为 'model'
if (extractedInfo.type === 'unknown') extractedInfo.type = 'model';
// a. 提取思维链 (使用 textContent)
if (!extractedInfo.thoughtText) { // 仅当思维链文本未记录时提取
console.log(" 尝试提取思维链...");
// 优先尝试之前的精确选择器
let thoughtNode = turn.querySelector('.thought-container .mat-expansion-panel-body ms-cmark-node.gmat-body-medium');
let thoughtText = thoughtNode ? thoughtNode.textContent.trim() : null; // *** 使用 textContent ***
console.log(` 查找精确思维链节点: ${thoughtNode ? '找到' : '未找到'}. Text: "${thoughtText}"`);
// 如果精确选择器失败或只取到标题,尝试直接获取父容器 `.mat-expansion-panel-body` 的 textContent
// *遇到的问题*:精确选择器有时找不到节点,或者 `ms-cmark-node` 的 `textContent` 可能为空。
// *解决方案*:增加对父容器的 `textContent` 的尝试。
if (!thoughtText || thoughtText.toLowerCase() === 'thinking process:') {
const panelBody = turn.querySelector('.thought-container .mat-expansion-panel-body');
if (panelBody) {
thoughtText = panelBody.textContent.trim(); // 获取父容器的 textContent
console.log(` 尝试 .mat-expansion-panel-body textContent: "${thoughtText}"`);
}
}
// 检查提取到的文本是否有效
if (thoughtText && thoughtText.toLowerCase() !== 'thinking process:') {
extractedInfo.thoughtText = thoughtText; // 存储
console.log(` 成功提取思维链文本`);
dataWasUpdatedThisTime = true; // 标记更新
} else if (thoughtText) { // 如果文本是标题或为空
console.log(` 思维链文本似乎只有标题或为空`);
} else { // 如果未能提取到任何文本
console.log(` 未能提取到思维链文本`);
}
} else {
console.log(" 思维链文本已存在"); // 已存在则跳过
}
// b. 提取 AI 回答
if (!extractedInfo.responseText) { // 仅当回答文本未记录时提取
console.log(" 尝试提取 AI 回答...");
let responseText = null; // 初始化回答文本
// *** 优先尝试精确选择器 + 优化内部提取 ***
const responseSelector = '.turn-content > ms-prompt-chunk:not(:has(.thought-container))'; // 选择不含思维链的 prompt-chunk
const responseChunks = turn.querySelectorAll(responseSelector); // 查找所有匹配的 chunk
console.log(` 查找精确回答 chunk (${responseSelector}): 找到 ${responseChunks.length} 个`);
if (responseChunks.length > 0) { // 如果找到了
let responseTextCombined = ""; // 用于合并文本
responseChunks.forEach((chunk, chunkIndex) => {
// *** 优化点:优先查找 chunk 内部的 ms-cmark-node ***
const cmarkNode = chunk.querySelector('ms-cmark-node');
let chunkText = "";
if (cmarkNode) { // 如果找到 cmark 节点
chunkText = cmarkNode.innerText.trim(); // 使用其 innerText
console.log(` 回答 chunk ${chunkIndex + 1}: 从 cmark-node 获取 innerText: "${chunkText}"`);
} else { // 如果找不到 cmark 节点
chunkText = chunk.innerText.trim(); // 退回使用整个 chunk 的 innerText
console.log(` 回答 chunk ${chunkIndex + 1}: 未找到 cmark-node,使用 chunk innerText: "${chunkText}"`);
}
if (chunkText) responseTextCombined += chunkText + "\n\n"; // 合并
});
responseText = responseTextCombined.trim() || null; // 清理并赋值
if(responseText) console.log(` 成功提取回答文本 (精确方法优化)`);
else console.log(` 精确方法找到 chunk 但未提取到文本`);
}
// *** 如果精确方法失败 (找不到 chunk 或提取不到文本),启用改进的后备逻辑 ***
// *遇到的问题*:之前的后备逻辑(获取整个 .turn-content 并替换)容易出错,提取到 UI 文本。
// *解决方案*:新的后备逻辑更智能,它查找所有 prompt-chunk,但明确跳过包含思维链的那个。
if (!responseText) {
console.log(" 精确方法失败,启用后备逻辑查找回答...");
// 后备逻辑:查找所有直接在 .turn-content 下的 ms-prompt-chunk
const allPromptChunks = turn.querySelectorAll('.turn-content > ms-prompt-chunk');
console.log(` 后备:找到 ${allPromptChunks.length} 个 prompt-chunk`);
let potentialResponseText = "";
allPromptChunks.forEach((chunk, chunkIndex) => {
// 检查这个 chunk 内部是否包含思维链容器
const hasThoughtContainer = chunk.querySelector('.thought-container');
if (!hasThoughtContainer) { // 如果 *不* 包含思维链,则认为它是可能的回答 chunk
const cmarkNode = chunk.querySelector('ms-cmark-node'); // 同样优先 cmarkNode
let chunkText = cmarkNode ? cmarkNode.innerText.trim() : chunk.innerText.trim();
console.log(` 后备:检查 chunk ${chunkIndex + 1} (无思维链),文本: "${chunkText}"`);
if (chunkText) {
potentialResponseText += chunkText + "\n\n"; // 合并
}
} else { // 如果包含思维链,则明确跳过
console.log(` 后备:跳过 chunk ${chunkIndex + 1} (包含思维链)`);
}
});
responseText = potentialResponseText.trim() || null; // 清理并赋值
if(responseText) console.log(` 成功提取回答文本 (后备方法)`);
else console.log(` 后备方法也未能提取到回答文本`);
}
// 如果最终提取到了回答文本
if (responseText) {
extractedInfo.responseText = responseText; // 存储
dataWasUpdatedThisTime = true; // 标记更新
}
} else {
console.log(" AI 回答文本已存在"); // 已存在则跳过
}
// c. 根据提取到的文本,更新最终的回合类型
if (extractedInfo.thoughtText && extractedInfo.responseText) {
extractedInfo.type = 'model_thought_reply'; // 同时有思维链和回答
} else if (extractedInfo.responseText) {
extractedInfo.type = 'model_reply'; // 只有回答
} else if (extractedInfo.thoughtText) {
extractedInfo.type = 'model_thought'; // 只有思维链
} else {
// 如果什么都没提取到,但我们知道它是模型回合,至少标记为 'model'
if (extractedInfo.type === 'unknown') extractedInfo.type = 'model';
}
console.log(` 最终回合类型判定为: ${extractedInfo.type}`); // 打印最终类型
} // end if model container check
// --- 更新 Map 中的数据 ---
// 只有在数据确实被更新时 (`dataWasUpdatedThisTime` 为 true) 才执行 `set` 操作,
// 这样可以避免不必要的 Map 写操作,并确保 `isNewTurn` 的逻辑(在上面)只在新回合第一次被处理时触发计数。
if (dataWasUpdatedThisTime) {
collectedData.set(turnKey, extractedInfo); // 更新 Map 中的记录
console.log(` 回合 ${index + 1}: 数据已更新 Map`);
dataUpdatedInExistingTurn = true; // 标记本次调用确实发生了数据更新
} else {
// 如果本次没有更新数据(可能是因为内容已存在,或提取失败)
console.log(` 回合 ${index + 1}: 本次无数据更新`);
}
}); // --- 结束遍历 currentTurns ---
// 打印本次提取的总结日志
console.log(`--- 本次提取结束,新增 ${newlyFoundCount} 条记录。当前总收集数: ${collectedData.size} ---`);
// 更新状态栏显示
updateStatus(`滚动 ${scrollCount}/${MAX_SCROLL_ATTEMPTS}... 已收集 ${collectedData.size} 条记录...`);
// 返回本次调用是否找到了新回合或更新了已有回合的数据
return newlyFoundCount > 0 || dataUpdatedInExistingTurn;
}
/**
* 异步执行自动向下滚动的过程。
* 循环执行:滚动 -> 等待 -> 提取数据,直到满足停止条件。
* @returns {Promise
} - 滚动过程是否成功启动并完成(或被停止)。
*/
async function autoScrollDown_AiStudio() {
console.log("启动自动滚动..."); // 日志
isScrolling = true; collectedData.clear(); scrollCount = 0; noChangeCounter = 0; // 初始化状态变量
const scroller = getMainScrollerElement_AiStudio(); // 获取滚动容器元素
if (!scroller) { // 启动失败处理
updateStatus('错误: 找不到滚动区域!');
alert('未能找到聊天记录的滚动区域,无法自动滚动。请检查脚本中的选择器。');
isScrolling = false; return false;
}
console.log('使用的滚动元素:', scroller); // 打印使用的滚动元素
const isWindowScroller = (scroller === document.documentElement || scroller === document.body); // 判断滚动目标
// 定义获取滚动信息的辅助函数,兼容窗口和元素滚动
const getScrollTop = () => isWindowScroller ? window.scrollY : scroller.scrollTop;
const getScrollHeight = () => isWindowScroller ? document.documentElement.scrollHeight : scroller.scrollHeight;
const getClientHeight = () => isWindowScroller ? window.innerHeight : scroller.clientHeight;
updateStatus(`开始增量滚动 (最多 ${MAX_SCROLL_ATTEMPTS} 次)...`); // 更新初始状态
let lastScrollHeight = -1; // 用于比较滚动高度是否变化
// --- 滚动主循环 ---
while (scrollCount < MAX_SCROLL_ATTEMPTS && isScrolling) { // 循环条件
const currentScrollTop = getScrollTop(); const currentScrollHeight = getScrollHeight(); const currentClientHeight = getClientHeight();
// 检查滚动高度是否稳定(触底判断)
if (currentScrollHeight === lastScrollHeight) { noChangeCounter++; } else { noChangeCounter = 0; }
lastScrollHeight = currentScrollHeight;
// *遇到的问题*:简单地检查 scrollTop + clientHeight >= scrollHeight 不可靠,因为内容可能在滚动后才加载导致 scrollHeight 变化。
// *解决方案*:使用稳定检查计数器 `noChangeCounter`。
if (noChangeCounter >= SCROLL_STABILITY_CHECKS && currentScrollTop + currentClientHeight >= currentScrollHeight - 20) { // 连续稳定且接近底部
console.log("滚动条疑似触底,停止滚动。");
updateStatus(`滚动完成 (疑似触底)。`);
break; // 退出循环
}
// 检查是否意外滚动回顶部
if (currentScrollTop === 0 && scrollCount > 10) { // 避免初始状态误判
console.log("滚动条返回顶部,停止滚动。");
updateStatus(`滚动完成 (返回顶部)。`);
break; // 退出循环
}
// 计算目标滚动位置并执行滚动
const targetScrollTop = currentScrollTop + (currentClientHeight * SCROLL_INCREMENT_FACTOR);
if (isWindowScroller) { window.scrollTo({ top: targetScrollTop, behavior: 'smooth' }); } else { scroller.scrollTo({ top: targetScrollTop, behavior: 'smooth' }); }
scrollCount++; // 增加滚动次数
// 更新状态,然后暂停等待内容加载
updateStatus(`滚动 ${scrollCount}/${MAX_SCROLL_ATTEMPTS}... 等待 ${SCROLL_DELAY_MS}ms... (已收集 ${collectedData.size} 条)`);
await delay(SCROLL_DELAY_MS); // **使用增加后的延迟**
// 调用数据提取函数
extractDataIncremental_AiStudio();
// 检查是否在等待或提取过程中被手动停止
if (!isScrolling) { console.log("检测到手动停止信号,退出滚动循环。"); break; }
} // --- 滚动主循环结束 ---
// --- 循环结束后的状态处理 ---
if (!isScrolling && scrollCount < MAX_SCROLL_ATTEMPTS) { // 如果是手动停止的
updateStatus(`滚动已手动停止 (共 ${scrollCount} 次尝试)。`);
console.log(`滚动被手动停止。`);
} else if (scrollCount >= MAX_SCROLL_ATTEMPTS) { // 如果是达到最大次数停止的
updateStatus(`滚动停止: 已达到最大尝试次数 (${MAX_SCROLL_ATTEMPTS})。`);
console.log(`滚动停止,达到最大尝试次数。`);
}
// (触底或返回顶部的情况已在循环内更新了状态)
isScrolling = false; // 确保最终重置滚动状态
return true; // 返回 true 表示滚动过程已结束(无论原因)
}
/**
* 格式化 `collectedData` 中收集到的聊天数据,生成 TXT 文件内容,并触发浏览器下载。
*/
function formatAndTriggerDownload() {
updateStatus(`处理 ${collectedData.size} 条记录并生成文件...`); // 更新状态
// --- 排序:使用最终的 DOM 顺序 ---
// *遇到的问题*:由于是动态加载和可能的 DOM 复用,不能依赖 `collectedData` Map 中元素的插入顺序。也没有可靠的时间戳信息。
// *解决方案*:在滚动结束后,重新查询页面上所有 `ms-chat-turn` 元素,它们的当前顺序就是最终的显示顺序。以此顺序来排列 `collectedData` 中的数据。
const finalTurnsInDom = document.querySelectorAll('ms-chat-turn'); // 获取最终 DOM 顺序
let sortedData = []; // 初始化用于存放排序后数据的数组
let missingKeysCount = 0; // 计数器:记录在 Map 中找不到的 DOM 节点数
finalTurnsInDom.forEach(turnNode => { // 遍历 DOM 节点
if (collectedData.has(turnNode)) { // 使用 DOM 节点作为 Key 在 Map 中查找
sortedData.push(collectedData.get(turnNode)); // 如果找到,按当前顺序添加到数组
} else {
// 如果 DOM 中存在这个节点,但在 Map 中找不到(理论上不应发生,除非提取时完全失败且未记录)
console.warn("警告:DOM 中找到一个 ms-chat-turn,但在收集的数据中找不到其记录。", turnNode);
missingKeysCount++; // 计数丢失的记录
}
});
console.log(`按 DOM 顺序整理后得到 ${sortedData.length} 条记录进行导出。`); // 打印排序后的记录数
if (missingKeysCount > 0) { // 如果有丢失的记录,打印警告
console.warn(`有 ${missingKeysCount} 个 DOM 回合在收集的数据中没有找到对应记录,可能未被完全处理。`);
}
// (可选) 检查 Map 中的数据是否比排序后的多(理论上不应发生,除非 DOM 元素被移除)
if (collectedData.size > sortedData.length) {
console.warn(`警告:收集到 ${collectedData.size} 条记录,但按 DOM 顺序只找到 ${sortedData.length} 条,可能存在已被移除的 DOM 元素。`);
}
// --- 检查是否有数据可导出 ---
if (sortedData.length === 0) { // 如果排序后一条记录都没有
updateStatus('没有收集到任何有效记录。');
alert('滚动结束后未能收集到任何聊天记录,无法导出。');
// 重置按钮状态并退出
captureButton.textContent = buttonTextStart; captureButton.disabled = false;
captureButton.classList.remove('success', 'error'); updateStatus('');
return; // 退出函数
}
// --- 构建 TXT 文件内容 ---
let fileContent = "Google AI Studio 聊天记录 (自动滚动捕获)\n"; // 文件标题
fileContent += "=========================================\n\n"; // 分隔线
sortedData.forEach(item => { // 遍历排序后的数据
let turnContent = ""; // 初始化当前回合的文本内容
// 根据回合类型 (`item.type`) 添加对应的文本和标识符
if (item.type === 'user' && item.userText) {
turnContent += `--- 用户 ---\n${item.userText}\n\n`;
} else if (item.type === 'model_thought' && item.thoughtText) { // 只有思维链
turnContent += `--- AI 思维链 ---\n${item.thoughtText}\n\n`;
} else if (item.type === 'model_reply' && item.responseText) { // 只有回答
turnContent += `--- AI 回答 ---\n${item.responseText}\n\n`;
} else if (item.type === 'model_thought_reply') { // 同时有思维链和回答
if(item.thoughtText) turnContent += `--- AI 思维链 ---\n${item.thoughtText}\n\n`; // 先加思维链
if(item.responseText) turnContent += `--- AI 回答 ---\n${item.responseText}\n\n`; // 再加回答
} else if (item.type === 'model' && !item.thoughtText && !item.responseText) {
// 如果是模型回合,但未能提取到任何文本内容,添加一个标记
turnContent += `--- 模型回合 (内容提取失败) ---\n\n`;
} else if (item.type === 'unknown') {
// 如果回合类型未知(通常意味着提取失败),也添加标记
turnContent += `--- 未知类型回合 (内容提取失败) ---\n\n`;
}
// 如果当前回合生成了有效内容,则将其添加到总文件内容中,并附加分隔线
if (turnContent) {
fileContent += turnContent.trim() + "\n\n------------------------------\n\n";
}
});
// 清理文件末尾可能多余的分隔线和空行
fileContent = fileContent.replace(/\n\n------------------------------\n\n$/, '\n').trim();
// --- 触发文件下载 ---
try {
// 1. 创建 Blob (Binary Large Object) 对象:将文本内容包装成一个文件对象,指定 MIME 类型为纯文本 UTF-8。
const blob = new Blob([fileContent], { type: 'text/plain;charset=utf-8' });
// 2. 创建一个隐藏的 `` HTML 元素,它将作为下载链接。
const link = document.createElement('a');
// 3. 使用 `URL.createObjectURL()` 为 Blob 数据生成一个临时的、唯一的 URL。
const url = URL.createObjectURL(blob);
// 4. 设置 `` 元素的 `href` 属性指向这个 Blob URL。
link.href = url;
// 5. 设置 `` 元素的 `download` 属性为期望的文件名。浏览器在用户点击此链接时,会下载 `href` 指向的内容,并使用 `download` 属性值作为文件名。
link.download = `${EXPORT_FILENAME_PREFIX}${getCurrentTimestamp()}.txt`; // 文件名包含前缀和当前时间戳
// 6. 将这个隐藏的链接元素添加到页面的 `` 中。
document.body.appendChild(link);
// 7. 使用 JavaScript 模拟用户点击这个链接,这将触发浏览器的文件下载对话框或自动开始下载。
link.click();
// 8. 下载触发后,从页面中移除这个临时的 `` 元素,保持 DOM 清洁。
document.body.removeChild(link);
// 9. 使用 `URL.revokeObjectURL()` 释放之前为 Blob 创建的临时 URL,通知浏览器可以回收相关资源。
URL.revokeObjectURL(url);
// 更新 UI 提示用户下载已开始
console.log(`AI Studio 聊天记录 (${sortedData.length}条) 已触发下载: ${link.download}`);
updateStatus(`文件 ${link.download} 已开始下载...`);
captureButton.textContent = successText; // 按钮显示成功文字
captureButton.classList.add('success'); // 按钮变绿
} catch (e) { // 如果在创建 Blob 或触发下载的过程中发生错误
console.error("导出文件失败:", e); // 在控制台打印详细错误信息
captureButton.textContent = `${errorText}: 创建失败`; // 按钮显示错误文字
captureButton.classList.add('error'); // 按钮变红
alert("创建下载文件时出错,请检查浏览器控制台日志获取详细信息。"); // 弹窗提示用户
updateStatus(`错误: ${e.message}`); // 在状态栏显示错误信息
}
// --- 重置按钮状态 ---
// 无论下载成功还是失败,都在设定的超时时间后,将按钮恢复到初始状态。
setTimeout(() => {
captureButton.textContent = buttonTextStart; // 恢复按钮文字
captureButton.disabled = false; // 重新启用按钮
captureButton.classList.remove('success', 'error'); // 移除成功或错误的样式类
updateStatus(''); // 清空状态栏信息
}, exportTimeout); // 使用配置的超时时间
}
/**
* “开始/导出”按钮被点击时触发的主函数。
* 这个函数负责启动和协调整个聊天记录导出流程:
* 改变按钮状态 -> 调用自动滚动函数 -> 等待滚动完成 -> 调用最终的数据提取 -> 调用格式化和下载函数 -> 处理错误 -> 恢复按钮状态。
*/
async function handleExtraction() {
if (isScrolling) return; // 如果当前已经在滚动中,则直接返回,防止用户重复点击导致问题
// --- 准备阶段:更新 UI,设置状态 ---
captureButton.disabled = true; // 禁用“开始/导出”按钮,防止在处理过程中再次点击
captureButton.textContent = '滚动中...'; // 更新按钮文字,提示用户正在进行滚动
stopButton.style.display = 'inline-block'; // 显示“停止滚动”按钮
stopButton.disabled = false; // 确保“停止滚动”按钮是可用的
stopButton.textContent = buttonTextStop; // 设置“停止滚动”按钮的文字
updateStatus('初始化滚动...'); // 更新状态栏信息
// --- 执行核心流程,使用 try...catch...finally 来确保健壮性 ---
try {
// 1. 调用自动滚动函数,并使用 await 等待其完成。
// `scrollSuccess` 会是 `autoScrollDown_AiStudio` 的返回值 (通常是 true,除非启动失败是 false)。
const scrollSuccess = await autoScrollDown_AiStudio();
// 2. 处理滚动结果
if (scrollSuccess !== false) { // 如果滚动过程成功启动并结束(无论是正常完成还是被手动停止)
captureButton.textContent = buttonTextProcessing; // 更新按钮文字为“处理中”
updateStatus('滚动结束,准备最终处理...');
await delay(500); // 短暂等待,确保滚动停止后页面 DOM 结构稳定
// 在滚动结束后,**再次调用**一次增量提取函数。
// 这是为了捕获可能在最后一次滚动操作之后、脚本检测到停止之前才完全加载或渲染的内容。
extractDataIncremental_AiStudio();
await delay(200); // 再稍等片刻,确保提取完成
// 调用格式化数据并触发文件下载的函数
formatAndTriggerDownload();
} else {
// 如果滚动启动失败 (例如 `getMainScrollerElement_AiStudio` 返回 null)
captureButton.textContent = `${errorText}: 滚动失败`; // 更新按钮为错误状态
captureButton.classList.add('error'); // 按钮变红
// 一段时间后恢复按钮的初始状态
setTimeout(() => {
captureButton.textContent = buttonTextStart;
captureButton.disabled = false;
captureButton.classList.remove('error');
updateStatus('');
}, exportTimeout);
}
} catch (error) { // 捕获在 try 块中(滚动、提取、格式化、下载等步骤)发生的任何未预料的 JavaScript 错误
console.error('处理过程中发生错误:', error); // 在控制台打印详细的错误信息和堆栈跟踪
updateStatus(`错误: ${error.message}`); // 在状态栏显示简洁的错误消息
alert(`处理过程中发生错误: ${error.message}`); // 弹出一个警告框提示用户发生了错误
captureButton.textContent = `${errorText}: 处理出错`; // 更新按钮为错误状态
captureButton.classList.add('error'); // 按钮变红
// 即使发生错误,也要尝试在一段时间后恢复按钮状态,允许用户重试
setTimeout(() => {
captureButton.textContent = buttonTextStart;
captureButton.disabled = false;
captureButton.classList.remove('error');
updateStatus('');
}, exportTimeout);
isScrolling = false; // 强制重置滚动状态标志,以防万一
} finally { // `finally` 块中的代码,无论 try/catch 的结果如何(成功、失败、中途返回),总会被执行
// 确保“停止”按钮最终被隐藏起来
stopButton.style.display = 'none';
// 再次确保滚动状态标志被重置为 false,为下一次运行做准备
isScrolling = false;
}
}
// --- 脚本初始化入口 ---
// 使用 `setTimeout` 来延迟 `createUI` 函数的执行。
// 这是因为油猴脚本通常在页面 DOM 结构加载完成(DOMContentLoaded)时或之后立即执行,
// 但现代 Web 应用(如 AI Studio)可能还需要执行大量的 JavaScript 来动态渲染页面内容。
// 延迟执行可以给页面更多的时间来完成初始化渲染,从而提高脚本找到所需元素并成功注入 UI 的概率。
console.log("Google AI Studio 导出脚本 (v1.0): 等待页面加载 (2.5秒)...");
setTimeout(createUI, 2500); // 设置延迟 2500 毫秒(2.5秒)后调用 `createUI` 函数来创建用户界面
})(); // IIFE 定义结束,并立即调用执行,启动脚本