// ==UserScript==
// @name Google AI Studio 聊天记录导出器
// @namespace http://tampermonkey.net/
// @version 1.0
// @description 自动滚动 Google AI Studio 聊天界面,捕获用户消息、AI 思维链和 AI 回答,并将完整对话导出为 TXT 文件。
// @author qwerty
// @match https://aistudio.google.com/*
// @grant GM_addStyle
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iIzAwNzhmZiI+PHBhdGggZD0iTTE5LjUgMi4yNWgtMTVjLTEuMjQgMC0yLjI1IDEuMDEtMi4yNSAyLjI1djE1YzAgMS4yNCAxLjAxIDIuMjUgMi4yNSAyLjI1aDE1YzEuMjQgMCAyLjI1LTEuMDEgMi4yNS0yLjI1di0xNWMwLTEuMjQtMS4wMS0yLjI1LTIuMjUtMi4yNXptLTIuMjUgNmgtMTAuNWMtLjQxIDAtLjc1LS4zNC0uNzUtLjc1cy4zNC0uNzUuNzUtLjc1aDEwLjVjLjQxIDAgLjc1LjM0Ljc1Ljc1cy0uMzQuNzUtLjc1Ljc1em0wIDRoLTEwLjVjLS40MSAwLS43NS0uMzQtLjc1LS43NXMuMzQtLjc1Ljc1LS43NWgxMC41Yy40MSAwIC43NS4zNC43NS43NXMtLjM0Ljc1LS43NS43NXptLTMgNGgtNy41Yy0uNDEgMC0uNzUtLjM0LS43NS0uNzVzLjM0LS43NS43NS0uNzVoNy41Yy40MSAwIC43NS4zNC43NS43NXMtLjM0Ljc1LS43NS43NXoiLz48L3N2Zz4=
// @license MIT
// ==/UserScript==
(function() { // 使用立即执行函数表达式 (Immediately Invoked Function Expression, IIFE) 来封装整个脚本
// 这样做的好处是创建一个独立的作用域,避免脚本内部的变量和函数污染全局命名空间,
// 同时也防止与其他可能在页面上运行的 JavaScript 代码发生冲突。
'use strict'; // 在脚本或函数的开头启用 JavaScript 的严格模式。
// 严格模式有助于开发者编写更安全、更可靠的代码,它会捕获一些常见的编码错误,
// 并禁止使用一些不推荐的 JavaScript 特性。
// --- 全局配置常量 ---
// 将脚本中使用的固定值或可配置参数定义为常量。这样做的好处是:
// 1. 易于查找和修改:所有配置项集中在一起。
// 2. 提高可读性:使用有意义的常量名代替硬编码的值。
// 3. 避免魔法数字/字符串:代码中直接使用常量名,意图更清晰。
// --- 按钮上显示的文本 ---
const buttonTextStart = "开始滚动并导出 TXT"; // “开始/导出”按钮在脚本加载完成、等待用户操作时的初始文本。
const buttonTextStop = "停止滚动"; // “停止滚动”按钮上显示的文本。
const buttonTextProcessing = "处理并生成文件..."; // 当脚本完成滚动、正在整理数据并准备生成下载文件时,“开始/导出”按钮临时显示的文本。
const successText = "TXT 文件已导出!"; // 当 TXT 文件成功生成并触发下载后,“开始/导出”按钮短暂显示的成功提示文本。
const errorText = "导出失败"; // 当脚本在执行过程中遇到错误时,“开始/导出”按钮短暂显示的失败提示文本。
// --- UI 提示信息的显示时长 ---
const exportTimeout = 3000; // 设置成功或失败的提示信息在按钮上显示多长时间后自动恢复为初始文本(单位:毫秒)。
// --- 导出文件相关配置 ---
const EXPORT_FILENAME_PREFIX = 'aistudio_chat_export_'; // 定义下载的 TXT 文件名的固定前缀部分。实际文件名将是这个前缀加上一个时间戳。
// --- 自动滚动行为相关配置 ---
const SCROLL_DELAY_MS = 2000; // **重要配置**:每次执行滚动操作后,脚本需要暂停多长时间(毫秒)来等待页面加载新的聊天内容。
// 这个值需要根据你的网络速度和 AI Studio 页面的响应速度进行调整。如果设置太短,可能内容还没加载出来脚本就继续滚动了;
// 如果设置太长,会增加总的导出时间。这里稍微增加到 2 秒,给渲染更多时间。
const MAX_SCROLL_ATTEMPTS = 300; // 设置脚本最多尝试执行多少次滚动操作。这是一个安全限制,防止在某些异常情况下(例如,无法正确检测到页面底部)脚本陷入无限滚动。
const SCROLL_INCREMENT_FACTOR = 0.85; // 定义每次向下滚动的步长。这个值是当前浏览器窗口可见区域高度的一个比例。例如,0.85 表示每次滚动大约 85% 的窗口高度。
// 较小的值滚动更平稳,但次数更多;较大的值滚动快,但可能跳过内容加载触发点。
const SCROLL_STABILITY_CHECKS = 3; // **重要配置**:用于判断是否滚动到底部的稳定检查次数。当脚本检测到滚动容器的总高度连续这么多次都没有变化时,
// 就假定已经到达了内容的底部(因为没有新内容加载进来使高度增加了)。这是比简单检查滚动条位置更可靠的方法。
// --- 脚本内部状态变量 ---
// 这些变量用于在脚本的整个生命周期中跟踪其当前的运行状态和收集到的数据。
let isScrolling = false; // 布尔值 (true/false),用来标记脚本当前是否正处于自动滚动的状态。这个标志用于控制滚动循环的启动和停止,并防止用户重复点击“开始”按钮。
let collectedData = new Map(); // 使用 JavaScript 的 `Map` 数据结构来存储所有收集到的聊天回合信息。
// **关键设计点**:Map 的 Key 直接使用每个聊天回合对应的 `ms-chat-turn` DOM 元素节点引用。
// 因为 DOM 节点引用是唯一的,这确保了即使在滚动过程中同一个聊天回合被多次看到,它在 Map 中也只会被记录一次。
// Map 的 Value 是一个对象,包含该回合的类型(用户/模型/思维链等)和提取到的文本内容。
// *遇到的问题*:最初尝试使用回合内元素的 ID 作为 Key,但发现一个回合可能包含多个带 ID 的元素(如思维链和回答),导致 Key 不唯一而出错。
// *解决方案*:改用 `ms-chat-turn` DOM 节点本身作为 Key,利用其引用的唯一性。
let scrollCount = 0; // 整数,用于记录从用户点击“开始”按钮后,脚本已经执行了多少次滚动操作。主要用于与 `MAX_SCROLL_ATTEMPTS` 比较,防止无限滚动。
let noChangeCounter = 0; // 整数,计数器,用于记录滚动容器的总高度 (`scrollHeight`) 连续多少次检查都没有发生变化。这是实现 `SCROLL_STABILITY_CHECKS` 的基础。
// --- UI 界面元素变量 ---
// 这些变量将在 `createUI` 函数中被初始化,指向由脚本动态创建并添加到页面上的各个 HTML 元素(按钮、状态显示区)。
// 将它们声明在这里(函数外部)是为了让脚本中的其他函数(如事件处理函数、状态更新函数)也能访问到这些元素。
let captureButton = null; // 将持有对“开始/导出”按钮 DOM 元素的引用。
let stopButton = null; // 将持有对“停止滚动”按钮 DOM 元素的引用。
let statusDiv = null; // 将持有对显示状态信息的 `<div>` DOM 元素的引用。
// --- 辅助工具函数 ---
// 这些是脚本内部使用的一些通用功能函数。
/**
* 返回一个 Promise,该 Promise 在指定的毫秒数后解决 (resolve)。
* 用于在异步代码中创建暂停,例如等待网络请求或页面渲染。
* @param {number} ms - 需要暂停等待的毫秒数。
* @returns {Promise<void>} - 一个将在指定时间后完成的 Promise。
*/
function delay(ms) {
// `setTimeout` 会在 `ms` 毫秒后执行提供的函数(这里是 `resolve`)。
// `resolve` 被调用时,这个 `Promise` 就进入了完成状态。
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 获取当前时间的格式化字符串,格式为 "YYYYMMDD_HHMMSS"。
* 主要用于生成带有时间戳的、不容易重复的文件名。
* @returns {string} - 格式化后的时间字符串。
*/
function getCurrentTimestamp() {
const n = new Date(); // 创建一个新的 Date 对象,表示当前的日期和时间。
const YYYY = n.getFullYear(); // 获取完整的四位数年份。
const MM = (n.getMonth() + 1).toString().padStart(2, '0'); // 获取月份(注意:月份是从 0 开始的,所以需要 +1)。`.toString()` 转为字符串,`.padStart(2, '0')` 确保月份总是两位数(例如,1 月是 '01' 而不是 '1')。
const DD = n.getDate().toString().padStart(2, '0'); // 获取月份中的日期(1-31),并补零至两位数。
const hh = n.getHours().toString().padStart(2, '0'); // 获取小时(0-23),并补零至两位数。
const mm = n.getMinutes().toString().padStart(2, '0'); // 获取分钟(0-59),并补零至两位数。
const ss = n.getSeconds().toString().padStart(2, '0'); // 获取秒钟(0-59),并补零至两位数。
// 使用模板字面量(反引号 ``)将所有部分拼接成所需的格式。
return `${YYYY}${MM}${DD}_${hh}${mm}${ss}`;
}
/**
* 尝试在 Google AI Studio 页面上找到负责内容滚动的主要 HTML 元素。
* **这是脚本中最容易出错的部分之一,因为它依赖于目标网站的内部结构。**
* 现代 Web 应用(尤其是像 AI Studio 这样基于框架构建的)的 DOM 结构可能很复杂,并且会随着更新而改变。
* 这个函数按优先级尝试了几种不同的 CSS 选择器策略来定位滚动容器。
* *遇到的问题*:很难有一个通用的选择器能完美适配所有情况和未来的更新。
* *解决方案*:提供多种查找策略,并留下警告,提示用户如果滚动不工作,最可能需要检查和修改这里。
* @returns {HTMLElement | Document} - 返回找到的滚动容器 DOM 元素。如果所有策略都失败,则返回 `document.documentElement`(代表整个文档),作为最后的尝试。
*/
function getMainScrollerElement_AiStudio() {
console.log("尝试查找滚动容器..."); // 日志记录查找操作的开始
// 策略 1: 尝试查找一个假设的、可能专门用于标识聊天滚动区域的 CSS 类名。
// 这个类名 `.chat-scrollable-container` 是基于常见的命名习惯推测的,**需要用户根据实际情况使用浏览器开发者工具(F12)检查并替换为正确的类名或选择器**。
let scroller = document.querySelector('.chat-scrollable-container');
// 检查是否找到了元素 (`scroller` 不为 null),并且该元素的内容总高度 (`scrollHeight`) 大于其在屏幕上可见的高度 (`clientHeight`)。只有当内容高度大于可见高度时,元素才真正需要滚动。
if (scroller && scroller.scrollHeight > scroller.clientHeight) {
console.log("找到滚动容器 (策略 1: .chat-scrollable-container):", scroller); // 找到则打印日志并返回该元素
return scroller;
}
// 策略 2: 尝试查找 Angular Material 框架中常用的一个组件标签名 `mat-sidenav-content`。
// Google 的很多 Web 应用使用 Angular Material,这个标签通常包裹主要内容区域,有时它本身就是滚动容器。
scroller = document.querySelector('mat-sidenav-content');
if (scroller && scroller.scrollHeight > scroller.clientHeight) {
console.log("找到滚动容器 (策略 2: mat-sidenav-content):", scroller);
return scroller;
}
// 策略 3: 如果前两种特定策略失败,尝试一种更通用的、基于结构的方法:
// a. 找到页面上第一个 `ms-chat-turn` 元素(假设聊天记录都在这个组件内)。
// b. 获取它的直接父元素。
const chatTurnsContainer = document.querySelector('ms-chat-turn')?.parentElement; // 使用可选链操作符 `?.` 防止在找不到 `ms-chat-turn` 时抛出错误。
if (chatTurnsContainer) { // 如果成功获取到父元素
let parent = chatTurnsContainer;
// c. 从这个父元素开始,向上遍历 DOM 树,最多检查 5 层祖先元素(避免无限向上查找)。
for (let i = 0; i < 5 && parent; i++) {
// d. 对每个祖先元素,检查它是否满足成为滚动容器的条件:
// i. 内容高度大于可见高度(`parent.scrollHeight > parent.clientHeight + 10`,加 10 像素容差是为了处理可能的边框或内边距影响)。
// ii. 并且,它的 CSS `overflow-y` 属性被设置为 'auto' 或 'scroll',表示浏览器允许在该元素上出现垂直滚动条。
if (parent.scrollHeight > parent.clientHeight + 10 &&
(window.getComputedStyle(parent).overflowY === 'auto' || window.getComputedStyle(parent).overflowY === 'scroll')) {
console.log("找到滚动容器 (策略 3: 向上查找父元素):", parent); // 找到符合条件的祖先,打印日志并返回
return parent;
}
parent = parent.parentElement; // 如果当前祖先不符合,继续检查它的父元素。
}
}
// 策略 4: 如果以上所有策略都失败了,打印一条警告信息到控制台,
// 并返回 `document.documentElement`(代表整个 HTML 文档的根元素)。
// 这意味着脚本将尝试滚动整个浏览器窗口,这在某些单页应用中可能是正确的,但在其他情况下可能效果不佳。
// 警告信息提示用户,如果滚动不正常,最可能需要回来修改这个函数的选择器。
console.warn("警告: 未能通过特定选择器精确找到 AI Studio 滚动区域,将尝试使用 document.documentElement。滚动效果可能不佳或不准确。请考虑检查并更新脚本中的 getMainScrollerElement_AiStudio 函数选择器。");
return document.documentElement;
}
// --- UI 界面创建与更新 ---
/**
* 创建脚本所需的用户界面元素(开始/导出按钮、停止按钮、状态显示区域),
* 为它们设置样式,绑定事件监听器,并将它们添加到当前网页的 `<body>` 中。
*/
function createUI() {
console.log("开始创建 UI 元素..."); // 日志记录
// --- 创建“开始/导出”按钮 ---
captureButton = document.createElement('button'); // 使用标准 DOM API 创建一个新的 <button> 元素
captureButton.textContent = buttonTextStart; // 设置按钮上显示的初始文字(来自常量)
captureButton.id = 'capture-chat-button'; // 为按钮设置一个唯一的 ID,方便后续通过 CSS 或脚本引用
// 使用 `style.cssText` 属性一次性设置多个 CSS 样式规则。这比逐个设置 `style.property` 更简洁。
captureButton.style.cssText = `
position: fixed; /* 使用固定定位,按钮位置相对于浏览器窗口固定 */
bottom: 20px; /* 距离窗口底部 20 像素 */
right: 150px; /* 距离窗口右侧 150 像素 (给停止按钮留出空间) */
z-index: 9999; /* 设置一个较高的堆叠顺序,确保按钮显示在页面其他内容之上 */
padding: 10px 15px; /* 设置按钮内部文字与边框的距离(内边距) */
background-color: #1a73e8; /* 设置背景颜色为 Google 蓝色 */
color: white; /* 设置文字颜色为白色 */
border: none; /* 移除默认的按钮边框 */
border-radius: 5px; /* 设置圆角 */
cursor: pointer; /* 鼠标悬停时显示手型指针 */
font-size: 14px; /* 设置字体大小 */
box-shadow: 2px 2px 5px rgba(0,0,0,0.2); /* 添加一个轻微的阴影效果,增加立体感 */
transition: all 0.3s ease; /* 为所有 CSS 属性的变化添加 0.3 秒的平滑过渡效果(例如背景色变化) */
`;
// 为按钮添加一个点击事件监听器。当用户点击这个按钮时,会调用 `handleExtraction` 函数来启动整个导出流程。
captureButton.addEventListener('click', handleExtraction);
// 将创建好的按钮元素添加到当前文档的 `<body>` 元素的末尾,使其在页面上可见。
document.body.appendChild(captureButton);
// --- 创建“停止滚动”按钮 ---
stopButton = document.createElement('button');
stopButton.textContent = buttonTextStop;
stopButton.id = 'stop-scrolling-button';
stopButton.style.cssText = `
position: fixed; bottom: 20px; right: 20px; z-index: 9999; /* 固定定位在页面右下角 */
padding: 10px 15px; background-color: #d93025; color: white; /* Google 红色背景 */
border: none; border-radius: 5px; cursor: pointer; font-size: 14px;
box-shadow: 2px 2px 5px rgba(0,0,0,0.2);
display: none; /* **关键**:初始状态设置为不显示 (display: none),只在滚动开始后才显示 */
transition: background-color 0.3s ease; /* 背景色变化时平滑过渡 */
`;
// 为停止按钮添加点击事件监听器
stopButton.addEventListener('click', () => {
if (isScrolling) { // 检查脚本当前是否真的在滚动状态
updateStatus('手动停止信号已发送...'); // 更新状态栏提示信息
isScrolling = false; // **核心**:将全局的 `isScrolling` 状态标志设置为 false。滚动循环会在下一次检查这个标志时退出。
stopButton.disabled = true; // 立刻禁用停止按钮,防止用户在脚本处理停止的过程中重复点击。
stopButton.textContent = '正在停止...'; // 临时改变按钮上的文字,给用户一个视觉反馈,表示正在响应停止操作。
}
});
document.body.appendChild(stopButton);
// --- 创建状态显示 DIV ---
statusDiv = document.createElement('div'); // 创建一个 <div> 元素
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<boolean>} - 滚动过程是否成功启动并完成(或被停止)。
*/
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. 创建一个隐藏的 `<a>` HTML 元素,它将作为下载链接。
const link = document.createElement('a');
// 3. 使用 `URL.createObjectURL()` 为 Blob 数据生成一个临时的、唯一的 URL。
const url = URL.createObjectURL(blob);
// 4. 设置 `<a>` 元素的 `href` 属性指向这个 Blob URL。
link.href = url;
// 5. 设置 `<a>` 元素的 `download` 属性为期望的文件名。浏览器在用户点击此链接时,会下载 `href` 指向的内容,并使用 `download` 属性值作为文件名。
link.download = `${EXPORT_FILENAME_PREFIX}${getCurrentTimestamp()}.txt`; // 文件名包含前缀和当前时间戳
// 6. 将这个隐藏的链接元素添加到页面的 `<body>` 中。
document.body.appendChild(link);
// 7. 使用 JavaScript 模拟用户点击这个链接,这将触发浏览器的文件下载对话框或自动开始下载。
link.click();
// 8. 下载触发后,从页面中移除这个临时的 `<a>` 元素,保持 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 定义结束,并立即调用执行,启动脚本