// ==UserScript==
// @name Google AI Studio 聊天记录导出器
// @namespace http://tampermonkey.net/
// @version 1.1
// @description 自动滚动 Google AI Studio 聊天界面,捕获用户消息、AI 思维链和 AI 回答,导出为 TXT 文件;或直接从 Python 代码块中提取对话并导出。
// @author qwerty
// @match https://aistudio.google.com/*
// @grant GM_addStyle
// @grant GM_setClipboard
// @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 buttonTextStartScroll = "开始滚动并导出 TXT"; // “滚动导出”按钮在脚本加载完成、等待用户操作时的初始文本。
const buttonTextStopScroll = "停止滚动"; // “停止滚动”按钮上显示的文本。
const buttonTextProcessingScroll = "处理滚动数据..."; // 当脚本完成滚动、正在整理数据并准备生成下载文件时,“滚动导出”按钮临时显示的文本。
const successTextScroll = "滚动导出 TXT 成功!"; // 当滚动导出的 TXT 文件成功生成并触发下载后,“滚动导出”按钮短暂显示的成功提示文本。
const errorTextScroll = "滚动导出失败"; // 当滚动导出过程中遇到错误时,“滚动导出”按钮短暂显示的失败提示文本。
// --- 代码块导出按钮文本 ---
const buttonTextStartCode = "一键导出代码块对话 (TXT)"; // 新增:“代码块导出”按钮在脚本加载完成、等待用户操作时的初始文本。
const buttonTextProcessingCode = "处理代码块数据..."; // 新增:当脚本正在解析代码块并准备生成下载文件时,“代码块导出”按钮临时显示的文本。
const successTextCode = "代码块导出 TXT 成功!"; // 新增:当代码块导出的 TXT 文件成功生成并触发下载后,“代码块导出”按钮短暂显示的成功提示文本。
const errorTextCode = "代码块导出失败"; // 新增:当代码块导出过程中遇到错误时,“代码块导出”按钮短暂显示的失败提示文本。
// --- UI 提示信息的显示时长 ---
const exportTimeout = 3000; // 设置成功或失败的提示信息在按钮上显示多长时间后自动恢复为初始文本(单位:毫秒)。
// --- 导出文件相关配置 ---
const EXPORT_FILENAME_PREFIX_SCROLL = 'aistudio_chat_scroll_export_'; // 定义滚动导出的 TXT 文件名的固定前缀部分。
const EXPORT_FILENAME_PREFIX_CODE = 'aistudio_code_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; // **重要配置**:用于判断是否滚动到底部的稳定检查次数。当脚本检测到滚动容器的总高度连续这么多次都没有变化时,
// 就假定已经到达了内容的底部(因为没有新内容加载进来使高度增加了)。这是比简单检查滚动条位置更可靠的方法。
// --- 代码块选择器 ---
// **重要**: 这个选择器用于定位包含 Python 代码的 `<code>` 元素。
// 初始值是基于用户提供的示例 (`code.code.gmat-body-medium`)。
// 如果 AI Studio 页面结构发生变化,导致这个选择器失效,用户需要:
// 1. 在浏览器中右键点击代码块区域,选择“检查”或“检查元素”。
// 2. 在开发者工具中找到包裹代码的 `<code>` 标签。
// 3. 观察其 class 属性或其他可用于定位的属性。
// 4. 修改下面的常量值为一个更可靠的 CSS 选择器。
const CODE_BLOCK_SELECTOR = 'code.code.gmat-body-medium'; // 新增:用于定位 Python 代码块的 CSS 选择器
// --- 脚本内部状态变量 (滚动导出) ---
// 这些变量用于在脚本的整个生命周期中跟踪其当前的运行状态和收集到的数据。
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 captureButtonScroll = null; // 持有对“滚动导出”按钮 DOM 元素的引用。
let stopButtonScroll = null; // 持有对“停止滚动”按钮 DOM 元素的引用。
let captureButtonCode = 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;
}
/**
* 新增:查找包含 Python 代码的 <code/> 元素 (用于代码块导出功能)。
* 它使用全局常量 `CODE_BLOCK_SELECTOR` 来定位元素。
* @returns {HTMLElement | null} - 返回找到的 code 元素,如果找不到则返回 null。
*/
function findCodeBlockElement() {
console.log(`尝试查找代码块元素 (选择器: ${CODE_BLOCK_SELECTOR})...`);
const codeElement = document.querySelector(CODE_BLOCK_SELECTOR);
if (codeElement) {
console.log("找到代码块元素:", codeElement);
} else {
// 如果找不到代码块,打印警告。这通常意味着 `CODE_BLOCK_SELECTOR` 需要更新。
console.warn(`警告 (代码块导出): 未能找到指定的代码块元素 (${CODE_BLOCK_SELECTOR})。请检查页面结构或更新脚本中的选择器。`);
}
return codeElement;
}
// --- UI 界面创建与更新 ---
/**
* 创建脚本所需的用户界面元素(滚动导出按钮、停止按钮、代码块导出按钮、状态显示区域),
* 为它们设置样式,绑定事件监听器,并将它们添加到当前网页的 `<body>` 中。
*/
function createUI() {
console.log("开始创建 UI 元素..."); // 日志记录
// --- 创建“滚动导出”按钮 ---
captureButtonScroll = document.createElement('button'); // 使用标准 DOM API 创建一个新的 <button> 元素
captureButtonScroll.textContent = buttonTextStartScroll; // 设置按钮上显示的初始文字(来自常量)
captureButtonScroll.id = 'capture-chat-scroll-button'; // 为按钮设置一个唯一的 ID
// 使用 `style.cssText` 属性一次性设置多个 CSS 样式规则。
captureButtonScroll.style.cssText = `
position: fixed; /* 使用固定定位,按钮位置相对于浏览器窗口固定 */
bottom: 20px; /* 距离窗口底部 20 像素 */
right: 260px; /* 距离窗口右侧像素,给其他按钮留出空间 */
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 秒的平滑过渡效果 */
`;
// 为按钮添加一个点击事件监听器。当用户点击这个按钮时,会调用 `handleScrollExtraction` 函数来启动滚动导出流程。
captureButtonScroll.addEventListener('click', handleScrollExtraction);
// 将创建好的按钮元素添加到当前文档的 `<body>` 元素的末尾,使其在页面上可见。
document.body.appendChild(captureButtonScroll);
// --- 创建“停止滚动”按钮 ---
stopButtonScroll = document.createElement('button');
stopButtonScroll.textContent = buttonTextStopScroll;
stopButtonScroll.id = 'stop-scrolling-button';
stopButtonScroll.style.cssText = `
position: fixed; bottom: 20px; right: 170px; /* 调整 right 值 */
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; /* 背景色变化时平滑过渡 */
`;
// 为停止按钮添加点击事件监听器
stopButtonScroll.addEventListener('click', () => {
if (isScrolling) { // 检查脚本当前是否真的在滚动状态
updateStatus('手动停止滚动信号已发送...'); // 更新状态栏提示信息
isScrolling = false; // **核心**:将全局的 `isScrolling` 状态标志设置为 false。滚动循环会在下一次检查这个标志时退出。
stopButtonScroll.disabled = true; // 立刻禁用停止按钮,防止用户在脚本处理停止的过程中重复点击。
stopButtonScroll.textContent = '正在停止...'; // 临时改变按钮上的文字,给用户一个视觉反馈,表示正在响应停止操作。
}
});
document.body.appendChild(stopButtonScroll);
// --- 新增:创建“代码块导出”按钮 ---
captureButtonCode = document.createElement('button');
captureButtonCode.textContent = buttonTextStartCode; // 设置按钮文字
captureButtonCode.id = 'capture-chat-code-button'; // 设置新 ID
captureButtonCode.style.cssText = `
position: fixed; bottom: 20px; right: 20px; /* 固定定位在页面右下角 */
z-index: 9999; padding: 10px 15px; background-color: #34a853; /* 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;
`;
// 为按钮添加点击事件监听器,调用 `handleCodeBlockExport` 函数
captureButtonCode.addEventListener('click', handleCodeBlockExport);
document.body.appendChild(captureButtonCode);
// --- 创建状态显示 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-scroll-button:disabled,
#stop-scrolling-button:disabled,
#capture-chat-code-button:disabled {
opacity: 0.6; /* 使按钮看起来半透明,表示不可用 */
cursor: not-allowed; /* 鼠标悬停时显示“禁止”图标 */
background-color: #aaa !important; /* 背景色变为灰色,使用 !important 确保能覆盖元素上可能存在的其他背景色设置 */
}
/* 定义“滚动导出”按钮在成功/错误状态下的样式 */
#capture-chat-scroll-button.success { background-color: #1e8e3e !important; /* Google 绿色 */ }
#capture-chat-scroll-button.error { background-color: #d93025 !important; /* Google 红色 */ }
/* 新增:定义“代码块导出”按钮在成功/错误状态下的样式 */
#capture-chat-code-button.success { background-color: #188038 !important; /* 深一点的 Google 绿色 */ }
#capture-chat-code-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;
let dataUpdatedInExistingTurn = false;
// console.log("--- 开始增量提取 (滚动) ---"); // 减少日志输出
const currentTurns = document.querySelectorAll('ms-chat-turn');
// console.log(`发现 ${currentTurns.length} 个 ms-chat-turn 元素 (滚动)`);
currentTurns.forEach((turn, index) => {
const turnKey = turn;
// console.log(`\n处理回合 ${index + 1} (滚动 Key: DOM Node)`);
const turnContainer = turn.querySelector('.chat-turn-container.user, .chat-turn-container.model');
if (!turnContainer) {
// console.warn(` 回合 ${index + 1} (滚动): 内部找不到 .user 或 .model 容器,跳过`);
return;
}
let isNewTurn = !collectedData.has(turnKey);
let extractedInfo = collectedData.get(turnKey) || {
domOrder: index,
type: 'unknown',
userText: null,
thoughtText: null,
responseText: null
};
if (isNewTurn) {
collectedData.set(turnKey, extractedInfo);
newlyFoundCount++;
// console.log(` 回合 ${index + 1} (滚动): 首次遇到,已记录`);
}
let dataWasUpdatedThisTime = false;
if (turnContainer.classList.contains('user')) {
// console.log(` 回合 ${index + 1} (滚动): 检测为 User`);
if (extractedInfo.type === 'unknown') extractedInfo.type = 'user';
if (!extractedInfo.userText) {
let userNode = turn.querySelector('.turn-content ms-cmark-node.user-chunk');
let userText = userNode ? userNode.innerText.trim() : null;
if (!userText) {
const turnContent = turn.querySelector('.turn-content');
if (turnContent) userText = turnContent.innerText.trim();
}
if (userText) {
extractedInfo.userText = userText;
// console.log(` (滚动) 成功提取用户文本: "${userText.substring(0, 50)}..."`);
dataWasUpdatedThisTime = true;
}
}
} else if (turnContainer.classList.contains('model')) {
// console.log(` 回合 ${index + 1} (滚动): 检测为 Model`);
if (extractedInfo.type === 'unknown') extractedInfo.type = 'model';
// a. 思维链
if (!extractedInfo.thoughtText) {
let thoughtNode = turn.querySelector('.thought-container .mat-expansion-panel-body ms-cmark-node.gmat-body-medium');
let thoughtText = thoughtNode ? thoughtNode.textContent.trim() : null;
if (!thoughtText || thoughtText.toLowerCase() === 'thinking process:') {
const panelBody = turn.querySelector('.thought-container .mat-expansion-panel-body');
if (panelBody) thoughtText = panelBody.textContent.trim();
}
if (thoughtText && thoughtText.toLowerCase() !== 'thinking process:') {
extractedInfo.thoughtText = thoughtText;
// console.log(` (滚动) 成功提取思维链文本`);
dataWasUpdatedThisTime = true;
}
}
// b. 回答
if (!extractedInfo.responseText) {
let responseText = null;
const responseSelector = '.turn-content > ms-prompt-chunk:not(:has(.thought-container))';
const responseChunks = turn.querySelectorAll(responseSelector);
if (responseChunks.length > 0) {
let responseTextCombined = "";
responseChunks.forEach((chunk) => {
const cmarkNode = chunk.querySelector('ms-cmark-node');
let chunkText = cmarkNode ? cmarkNode.innerText.trim() : chunk.innerText.trim();
if (chunkText) responseTextCombined += chunkText + "\n\n";
});
responseText = responseTextCombined.trim() || null;
}
if (!responseText) {
const allPromptChunks = turn.querySelectorAll('.turn-content > ms-prompt-chunk');
let potentialResponseText = "";
allPromptChunks.forEach((chunk) => {
const hasThoughtContainer = chunk.querySelector('.thought-container');
if (!hasThoughtContainer) {
const cmarkNode = chunk.querySelector('ms-cmark-node');
let chunkText = cmarkNode ? cmarkNode.innerText.trim() : chunk.innerText.trim();
if (chunkText) potentialResponseText += chunkText + "\n\n";
}
});
responseText = potentialResponseText.trim() || null;
}
if (responseText) {
extractedInfo.responseText = responseText;
// console.log(` (滚动) 成功提取回答文本`);
dataWasUpdatedThisTime = true;
}
}
// 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 if (extractedInfo.type === 'unknown') extractedInfo.type = 'model';
// console.log(` (滚动) 最终回合类型判定为: ${extractedInfo.type}`);
}
if (dataWasUpdatedThisTime) {
collectedData.set(turnKey, extractedInfo);
// console.log(` 回合 ${index + 1} (滚动): 数据已更新 Map`);
dataUpdatedInExistingTurn = true;
}
});
// 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 表示滚动过程已结束(无论原因)
}
/**
* 格式化滚动收集的数据并触发 TXT 下载 (用于滚动导出)。
*/
function formatAndTriggerDownloadScroll() {
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} 条。`);
}
// --- 检查是否有数据可导出 ---
if (sortedData.length === 0) { // 如果排序后一条记录都没有
updateStatus('没有收集到任何有效滚动记录。');
alert('滚动结束后未能收集到任何聊天记录,无法导出。');
// 重置按钮状态并退出
captureButtonScroll.textContent = buttonTextStartScroll; captureButtonScroll.disabled = false;
captureButtonScroll.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_SCROLL}${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} 已开始下载...`);
captureButtonScroll.textContent = successTextScroll; // 使用滚动导出的成功文本
captureButtonScroll.classList.add('success'); // 按钮变绿
} catch (e) { // 如果在创建 Blob 或触发下载的过程中发生错误
console.error("滚动导出文件失败:", e); // 在控制台打印详细错误信息
captureButtonScroll.textContent = `${errorTextScroll}: 创建失败`; // 使用滚动导出的失败文本
captureButtonScroll.classList.add('error'); // 按钮变红
alert("创建滚动下载文件时出错,请检查浏览器控制台日志获取详细信息。"); // 弹窗提示用户
updateStatus(`错误 (滚动导出): ${e.message}`); // 在状态栏显示错误信息
}
// --- 重置按钮状态 ---
// 无论下载成功还是失败,都在设定的超时时间后,将按钮恢复到初始状态。
setTimeout(() => {
captureButtonScroll.textContent = buttonTextStartScroll; // 恢复按钮文字
captureButtonScroll.disabled = false; // 重新启用按钮
captureButtonScroll.classList.remove('success', 'error'); // 移除成功或错误的样式类
updateStatus(''); // 清空状态栏信息
}, exportTimeout); // 使用配置的超时时间
}
/**
* “滚动导出”按钮被点击时触发的主函数。
* 这个函数负责启动和协调整个聊天记录导出流程:
* 改变按钮状态 -> 调用自动滚动函数 -> 等待滚动完成 -> 调用最终的数据提取 -> 调用格式化和下载函数 -> 处理错误 -> 恢复按钮状态。
*/
async function handleScrollExtraction() {
if (isScrolling) return; // 如果当前已经在滚动中,则直接返回,防止用户重复点击导致问题
// --- 准备阶段:更新 UI,设置状态 ---
captureButtonScroll.disabled = true; // 禁用“滚动导出”按钮,防止在处理过程中再次点击
captureButtonScroll.textContent = '滚动中...'; // 更新按钮文字,提示用户正在进行滚动
stopButtonScroll.style.display = 'inline-block'; // 显示“停止滚动”按钮
stopButtonScroll.disabled = false; // 确保“停止滚动”按钮是可用的
stopButtonScroll.textContent = buttonTextStopScroll; // 设置“停止滚动”按钮的文字
updateStatus('初始化滚动 (滚动导出)...'); // 更新状态栏信息
// --- 执行核心流程,使用 try...catch...finally 来确保健壮性 ---
try {
// 1. 调用自动滚动函数,并使用 await 等待其完成。
// `scrollSuccess` 会是 `autoScrollDown_AiStudio` 的返回值 (通常是 true,除非启动失败是 false)。
const scrollSuccess = await autoScrollDown_AiStudio();
// 2. 处理滚动结果
if (scrollSuccess !== false) { // 如果滚动过程成功启动并结束(无论是正常完成还是被手动停止)
captureButtonScroll.textContent = buttonTextProcessingScroll; // 更新按钮文字为“处理中”
updateStatus('滚动结束,准备最终处理 (滚动导出)...');
await delay(500); // 短暂等待,确保滚动停止后页面 DOM 结构稳定
// 在滚动结束后,**再次调用**一次增量提取函数。
// 这是为了捕获可能在最后一次滚动操作之后、脚本检测到停止之前才完全加载或渲染的内容。
extractDataIncremental_AiStudio();
await delay(200); // 再稍等片刻,确保提取完成
// 调用格式化数据并触发文件下载的函数
formatAndTriggerDownloadScroll();
} else {
// 如果滚动启动失败 (例如 `getMainScrollerElement_AiStudio` 返回 null)
captureButtonScroll.textContent = `${errorTextScroll}: 滚动失败`; // 更新按钮为错误状态
captureButtonScroll.classList.add('error'); // 按钮变红
// 一段时间后恢复按钮的初始状态
setTimeout(() => {
captureButtonScroll.textContent = buttonTextStartScroll;
captureButtonScroll.disabled = false;
captureButtonScroll.classList.remove('error');
updateStatus('');
}, exportTimeout);
}
} catch (error) { // 捕获在 try 块中(滚动、提取、格式化、下载等步骤)发生的任何未预料的 JavaScript 错误
console.error('滚动处理过程中发生错误:', error); // 在控制台打印详细的错误信息和堆栈跟踪
updateStatus(`错误 (滚动导出): ${error.message}`); // 在状态栏显示简洁的错误消息
alert(`滚动处理过程中发生错误: ${error.message}`); // 弹出一个警告框提示用户发生了错误
captureButtonScroll.textContent = `${errorTextScroll}: 处理出错`; // 更新按钮为错误状态
captureButtonScroll.classList.add('error'); // 按钮变红
// 即使发生错误,也要尝试在一段时间后恢复按钮状态,允许用户重试
setTimeout(() => {
captureButtonScroll.textContent = buttonTextStartScroll;
captureButtonScroll.disabled = false;
captureButtonScroll.classList.remove('error');
updateStatus('');
}, exportTimeout);
isScrolling = false; // 强制重置滚动状态标志,以防万一
} finally { // `finally` 块中的代码,无论 try/catch 的结果如何(成功、失败、中途返回),总会被执行
// 确保“停止”按钮最终被隐藏起来
stopButtonScroll.style.display = 'none';
// 再次确保滚动状态标志被重置为 false,为下一次运行做准备
isScrolling = false;
}
}
// --- 新增:核心业务逻辑 (代码块导出) ---
/**
* 从 Python 代码块字符串中提取对话内容 (最终版 - 行状态机)。
* 此函数通过逐行解析代码文本,并维护一个状态机来识别和提取对话结构。
*
* **开发过程中的挑战与解决方案:**
* 1. **初步尝试 (Regex on Full Text):** 最初尝试使用复杂的正则表达式直接匹配整个代码块的 `textContent`。
* * **困难:** 失败。因为 `textContent` 会移除 HTML 标签(如用于语法高亮的 `<span>`),
* 并在拼接剩余文本节点时可能引入与原始代码字符串不一致的空格或换行符,导致 Regex 匹配失败。
* 2. **改进尝试 (Regex on `contents` Array):** 尝试先用 Regex 定位 `contents = [...]` 数组部分,然后只对这部分应用对话提取 Regex。
* * **困难:** 仍然失败。虽然缩小了范围,但 `textContent` 对数组内部的格式化(尤其是跨行的 `types.Content` 和 `types.Part` 定义)
* 同样会产生细微的空格/换行差异,干扰 Regex。
* 3. **行解析尝试 (Basic State Machine):** 改用逐行解析,通过查找特定行的起始字符串(如 `types.Content(`)来判断结构。
* * **困难:** 文本提取基本成功,但 `role` 属性提取失败。原因是脚本错误地假设 `role=` 总是与 `types.Content(` 在同一行。
* 而实际代码中,`role=` 通常在 `types.Content(` 的下一行。
* 4. **最终方案 (Enhanced State Machine):**
* * **解决方案:** 采用更精细的行状态机。当检测到 `types.Content(` 时,不立即提取 Role,而是切换到 `'寻找 Role'` 状态。
* * 在 `'寻找 Role'` 状态下,专门处理下一行(或后续几行),使用一个简单的、容错性强的 Regex (`/role\s*=\s*["'](user|model)["']/`) 来查找 `role` 属性。
* * 文本提取部分 (`types.Part.from_text(text="""...""")`) 逻辑保持行解析,通过查找起始和结束的 `"""` 标记,并能正确处理单行和多行文本。
* * 这种方法对 `textContent` 处理 `<span>` 标签时可能引入的空格和换行变化具有最高的鲁棒性。
*
* @param {string} codeText - 包含 Python 代码的字符串 (通常来自 `codeElement.textContent`)。
* @returns {Array<object>} - 包含对话回合对象的数组,格式如: [{ type: 'user'/'thought'/'response', text: '...' }, ...]
*/
function extractConversationFromCodeBlock(codeText) {
console.log("--- 开始代码块提取 (最终版 - 行状态机) ---");
const conversation = [];
const lines = codeText.split('\n'); // 按行分割代码
// 定义状态机的可能状态
// '寻找 contents': 初始状态,查找 `contents = [` 这行
// '寻找 Content': 在 contents 数组内,查找 `types.Content(` 这行
// '寻找 Role': 找到了 `types.Content(`,准备在下一行查找 `role=`
// '寻找 Part 开始': 找到了 Role (或确定为 unknown),开始查找 `types.Part.from_text(text="""`
// '读取 Part 文本': 找到了 Part 的起始 `"""`,正在读取多行文本,直到遇到结束的 `"""`
// '结束': 解析完成或遇到数组结束符 `]`
let state = '寻找 contents';
let current_content_block = null; // 临时存储当前正在处理的 Content 块信息 { role: 'user'|'model'|'unknown', parts: [] }
let current_part_lines = []; // 临时存储多行 Part 文本
// 用于从包含 `role=` 的行中提取 'user' 或 'model' 的正则表达式
// \s* 匹配零个或多个空白字符,["'] 匹配双引号或单引号
const roleExtractRegex = /role\s*=\s*["'](user|model)["']/;
console.log(`开始逐行解析 ${lines.length} 行...`);
// 遍历代码的每一行
for (let i = 0; i < lines.length; i++) {
const line = lines[i]; // 当前行原始内容
const trimmedLine = line.trim(); // 去除前后空白的行内容
// 根据当前状态执行不同的逻辑
switch (state) {
case '寻找 contents':
// 如果当前行包含 'contents = [',说明找到了对话列表的开始
if (trimmedLine.includes('contents = [')) {
console.log(`[行 ${i + 1}] 找到 'contents = [' -> 寻找 Content`);
state = '寻找 Content'; // 切换到寻找 Content 块的状态
}
// 如果没找到,继续处理下一行
break;
case '寻找 Content':
// 如果当前行以 'types.Content(' 开头,说明找到了一个新的对话回合
if (trimmedLine.startsWith('types.Content(')) {
console.log(`[行 ${i + 1}] 找到 'types.Content(' -> 寻找 Role`);
// 在开始处理新的 Content 之前,先处理并保存上一个收集到的 Content (如果存在)
if (current_content_block) {
processCollectedContent(current_content_block, conversation);
}
current_content_block = null; // 重置临时块,等待找到 Role 后再创建
state = '寻找 Role'; // **关键**: 切换状态,准备在下一行找 Role
}
// 如果在寻找 Content 时遇到了数组的结束符 ']'
else if (trimmedLine === ']') {
console.log(`[行 ${i + 1}] 找到 ']' -> 结束`);
// 处理最后一个可能未处理的 Content 块
if (current_content_block) {
processCollectedContent(current_content_block, conversation);
}
state = '结束'; // 标记解析结束
}
// 如果不是 Content 的开始也不是数组结束,忽略这行,继续寻找
break;
// 新增状态:专门用于查找 Role 定义行
case '寻找 Role':
console.log(` [行 ${i + 1}] 状态: 寻找 Role, Line: "${line}"`);
let role = 'unknown'; // 默认角色为 unknown
// 在当前行尝试用 Regex 匹配 role='...' 或 role="..."
const roleMatch = roleExtractRegex.exec(line);
if (roleMatch && roleMatch[1]) { // 如果匹配成功,并且捕获到了 'user' 或 'model'
role = roleMatch[1]; // 获取捕获到的角色
console.log(` 成功提取 Role: ${role}`);
} else {
// 如果当前行不是空行或简单的逗号,并且没有匹配到 Role,则发出警告
if (trimmedLine && trimmedLine !== ',') {
console.warn(` [行 ${i + 1}] 警告: 在寻找 Role 状态下未能提取 Role。Line: "${line}"`);
}
// **健壮性处理**: 如果在找到下一个 Part 或 Content 结束之前都没找到 Role 定义,
// 脚本会认为这个 Content 的 Role 是 'unknown'。
// 检查当前行是否包含 Part 开始或 Content 结束标记
if (line.includes('types.Part.from_text') || trimmedLine.startsWith('],') || trimmedLine === '],') {
console.warn(` 在找到 Part 或 Content 结束前仍未找到 Role,将使用 'unknown'。`);
current_content_block = { role: 'unknown', parts: [] }; // 确定 Role 为 unknown
state = '寻找 Part 开始'; // 切换状态,开始找 Part
// **重要**: 不能 break 或 continue,因为 Part 可能就在这一行,需要让后续 case 处理
} else {
// 如果当前行既没有 Role 也没有 Part/结束标记,则假设 Role 可能在下一行,继续循环
break; // 跳过本行后续处理,继续在下一行寻找 Role
}
}
// 如果上面没有 break (即找到了 Role 或确定为 unknown),则创建 Content 块
if (!current_content_block) { // 只有在上面没有因为找不到 Role 而创建时才创建
current_content_block = { role: role, parts: [] };
}
state = '寻找 Part 开始'; // 切换到寻找 Part 的状态
// **重要**: 不能 break 或 continue,因为 Part 可能就在这一行
case '寻找 Part 开始':
// 必须先有了一个 Content 块才能找 Part
if (!current_content_block) break;
// 查找 'types.Part.from_text(text="""' 的特征
const partStartIndex = line.indexOf('types.Part.from_text');
const textMarkerIndex = line.indexOf('text=');
const tripleQuoteIndex = line.indexOf('"""');
// 确保这些标记都在,并且顺序大致正确
if (partStartIndex !== -1 && textMarkerIndex > partStartIndex && tripleQuoteIndex > textMarkerIndex) {
console.log(` [行 ${i + 1}] 找到 'types.Part.from_text(text="""' -> 读取 Part 文本`);
state = '读取 Part 文本'; // 切换到读取文本状态
current_part_lines = []; // 清空上一部分的文本行
// 获取 """ 后面的文本
const textAfterTripleQuote = line.substring(tripleQuoteIndex + 3);
// 检查结束的 """ 是否也在同一行
const endTripleQuoteIndexSameLine = textAfterTripleQuote.indexOf('"""');
if (endTripleQuoteIndexSameLine !== -1) {
// --- 处理单行 Part 文本 ---
const singleLineText = textAfterTripleQuote.substring(0, endTripleQuoteIndexSameLine);
console.log(` 检测到单行 Part 文本: "${singleLineText.trim()}"`);
current_content_block.parts.push(singleLineText.trim()); // 直接添加处理好的文本
state = '寻找 Part 开始'; // 处理完毕,继续寻找下一个 Part
} else {
// --- 处理多行 Part 文本的起始行 ---
// 将 """ 后面的内容作为多行文本的第一部分
current_part_lines.push(textAfterTripleQuote);
}
}
// 如果遇到 Content 块的结束标记 '],'
else if (trimmedLine.startsWith('],') || trimmedLine === '],') {
console.log(`[行 ${i + 1}] 找到 Content 结束 '],' -> 寻找 Content`);
state = '寻找 Content'; // 切换回寻找下一个 Content 的状态
}
// 如果在寻找 Part 时直接遇到了数组的结束符 ']'
else if (trimmedLine === ']') {
console.log(`[行 ${i + 1}] 找到数组结束 ']' -> 结束`);
// 处理最后一个 Content 块
if (current_content_block) {
processCollectedContent(current_content_block, conversation);
}
state = '结束'; // 标记解析结束
}
// 如果不是 Part 的开始,也不是 Content 或数组的结束,忽略这行
break; // 结束 '寻找 Part 开始' 状态的处理
case '读取 Part 文本':
// 查找结束的 """
const endTripleQuoteIndex = line.indexOf('"""');
if (endTripleQuoteIndex !== -1) {
// --- 处理多行 Part 文本的结束行 ---
console.log(` [行 ${i + 1}] 找到 Part 结束 '"""' -> 寻找 Part 开始`);
// 获取 """ 之前的内容
const textBeforeEnd = line.substring(0, endTripleQuoteIndex);
current_part_lines.push(textBeforeEnd); // 添加最后一部分文本
// 合并所有收集到的行,去除首尾空白
const fullPartText = current_part_lines.join('\n').trim();
if (current_content_block) {
current_content_block.parts.push(fullPartText); // 添加到当前 Content 块
console.log(` 完整 Part 文本已收集 (前 80 字符): "${fullPartText.substring(0, 80)}..."`);
}
current_part_lines = []; // 清空,为下一个 Part 做准备
state = '寻找 Part 开始'; // 切换回寻找下一个 Part 的状态
} else {
// --- 处理多行 Part 文本的中间行 ---
// 如果还没找到结束的 """,将整行添加到当前 Part 的文本中
current_part_lines.push(line);
}
break; // 结束 '读取 Part 文本' 状态的处理
} // End switch
// 如果状态被标记为 '结束',则跳出循环
if (state === '结束') {
break;
}
} // End for loop through lines
// --- 循环结束后 ---
// 确保处理最后一个收集到的 Content 块 (除非解析从未开始或已正常结束)
if (state !== '寻找 contents' && state !== '结束' && current_content_block) {
console.log("循环结束,处理最后一个收集到的 Content 块。");
processCollectedContent(current_content_block, conversation);
}
// 如果循环结束时状态不是 '结束',可能意味着代码块不完整或解析逻辑有误
if (state !== '结束') {
console.warn(`警告: 循环结束时状态为 '${state}',可能解析不完整。`);
}
// --- 返回结果 ---
if (conversation.length === 0) {
console.warn("代码块提取完成: 未能提取到任何有效的对话部分。");
} else {
console.log(`代码块提取完成: 共提取到 ${conversation.length} 个对话部分。`);
}
console.log("--- 结束代码块提取 ---");
return conversation; // 返回包含提取结果的数组
}
/**
* 辅助函数:处理收集到的一个 Content 块,将其 Parts 分类并添加到最终的 conversation 数组。
* 这个函数将解析出的 `current_content_block`(包含 role 和 parts 文本数组)
* 转换为最终 `conversation` 数组所需的格式({ type: 'user'/'thought'/'response'/'unknown_part', text: '...' })。
* @param {object} contentBlock - 格式为 { role: string, parts: string[] } 的对象。
* @param {Array<object>} conversation - 要添加到的最终对话数组。
*/
function processCollectedContent(contentBlock, conversation) {
console.log(` 处理收集到的 Content (Role: ${contentBlock.role}, Parts: ${contentBlock.parts.length})`);
// 基本的健壮性检查
if (!contentBlock || !contentBlock.parts || contentBlock.parts.length === 0) {
console.warn(" 警告: processCollectedContent 接收到无效或空的 contentBlock。");
return;
}
// 根据 Role 处理 Parts
if (contentBlock.role === 'user') {
// 用户回合通常只有一个 Part
conversation.push({ type: 'user', text: contentBlock.parts[0] || "" });
console.log(` 添加为 User: "${(contentBlock.parts[0] || "").substring(0, 80)}..."`);
} else if (contentBlock.role === 'model') {
// 模型回合可能有多个 Parts (思维链 + 回答)
contentBlock.parts.forEach((partText, index) => {
// 通过检查文本是否以 "Thinking Process:" 开头来判断是否为思维链
if (partText.startsWith('Thinking Process:')) {
conversation.push({ type: 'thought', text: partText });
console.log(` 添加 Part ${index + 1} 为 Thought`);
} else {
// 否则认为是 AI 的最终回答
conversation.push({ type: 'response', text: partText });
console.log(` 添加 Part ${index + 1} 为 Response: "${partText.substring(0, 80)}..."`);
}
});
} else {
// 处理 Role 未知的情况 (解析 Role 失败时发生)
console.warn(` 警告: 未知角色 "${contentBlock.role}",尝试按顺序添加 Parts。`);
contentBlock.parts.forEach((partText, index) => {
// 尝试根据内容猜测一下是否为思维链
if (partText.startsWith('Thinking Process:')) {
conversation.push({ type: 'thought', text: partText });
console.log(` (未知 Role) 添加 Part ${index + 1} 为 Thought (基于内容猜测)`);
} else {
// 无法确定是 user 还是 response,标记为 unknown_part
conversation.push({ type: 'unknown_part', text: partText });
console.log(` (未知 Role) 添加 Part ${index + 1} 为 Unknown Part: "${partText.substring(0, 80)}..."`);
}
});
}
}
/**
* 新增:格式化从代码块提取的数据并触发 TXT 下载。
* @param {Array<object>} conversationData - 从 extractConversationFromCodeBlock 返回的数组。
*/
function formatAndDownloadCodeChat(conversationData) {
updateStatus(buttonTextProcessingCode); // 更新状态为处理中
// 检查是否有有效数据
if (!conversationData || conversationData.length === 0) {
updateStatus('错误 (代码块): 未提取到有效对话内容。');
alert('未能从代码块中提取到任何有效的对话内容,无法导出。');
// 重置按钮状态
captureButtonCode.textContent = buttonTextStartCode;
captureButtonCode.disabled = false;
captureButtonCode.classList.remove('success', 'error');
updateStatus('');
return;
}
// --- 构建 TXT 文件内容 ---
let fileContent = "代码块对话记录 (提取自 Python 代码)\n"; // 文件标题
fileContent += "=========================================\n\n"; // 分隔线
conversationData.forEach(item => { // 遍历提取出的每个部分
// 根据类型添加不同的标记头
switch (item.type) {
case 'user':
fileContent += `--- 用户输出 ---\n${item.text}\n\n`;
break;
case 'thought':
fileContent += `--- AI 思维链 ---\n${item.text}\n\n`;
break;
case 'response':
fileContent += `--- AI 输出 ---\n${item.text}\n\n`;
break;
case 'unknown_part': // 处理解析 Role 失败的部分
fileContent += `--- 未知部分 (解析 Role 失败?) ---\n${item.text}\n\n`;
break;
default: // 处理其他未预期的类型
fileContent += `--- 未知类型 (${item.type}) ---\n${item.text}\n\n`;
}
fileContent += "-----------------------------------------\n\n"; // 每个部分后的分隔线
});
// 清理文件末尾可能多余的分隔线和空行
fileContent = fileContent.replace(/\n\n-----------------------------------------\n\n$/, '\n').trim();
// --- 触发文件下载 (复用核心逻辑) ---
try {
// 创建 Blob 对象
const blob = new Blob([fileContent], { type: 'text/plain;charset=utf-8' });
// 创建隐藏的下载链接
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.href = url;
// 设置下载文件名,包含代码块导出前缀和时间戳
link.download = `${EXPORT_FILENAME_PREFIX_CODE}${getCurrentTimestamp()}.txt`;
document.body.appendChild(link);
// 模拟点击下载
link.click();
// 清理
document.body.removeChild(link);
URL.revokeObjectURL(url);
// 更新 UI 提示成功
console.log(`代码块对话记录 (${conversationData.length} 部分) 已触发下载: ${link.download}`);
updateStatus(`文件 ${link.download} 已开始下载...`);
captureButtonCode.textContent = successTextCode; // 显示成功文本
captureButtonCode.classList.add('success'); // 按钮变绿
} catch (e) { // 处理下载过程中的错误
console.error("代码块导出文件失败:", e);
captureButtonCode.textContent = `${errorTextCode}: 创建失败`; // 显示失败文本
captureButtonCode.classList.add('error'); // 按钮变红
alert("创建代码块下载文件时出错,请检查浏览器控制台日志获取详细信息。");
updateStatus(`错误 (代码块导出): ${e.message}`);
}
// --- 重置按钮状态 ---
// 无论成功或失败,都在超时后恢复按钮
setTimeout(() => {
captureButtonCode.textContent = buttonTextStartCode;
captureButtonCode.disabled = false;
captureButtonCode.classList.remove('success', 'error');
updateStatus('');
}, exportTimeout);
}
/**
* 新增:“代码块导出”按钮的点击处理函数。
*/
function handleCodeBlockExport() {
console.log("“代码块导出”按钮被点击。");
updateStatus('正在查找代码块...');
captureButtonCode.disabled = true; // 禁用按钮,防止重复点击
captureButtonCode.textContent = '查找代码块...'; // 更新按钮文本
// 使用 setTimeout 延迟执行,确保 UI 更新先生效,并给页面一点响应时间
setTimeout(() => {
// 1. 查找代码块元素
const codeElement = findCodeBlockElement();
// 2. 检查是否找到元素
if (!codeElement) {
updateStatus(`错误: 未找到代码块 (选择器: ${CODE_BLOCK_SELECTOR})`);
alert(`未能找到指定的 Python 代码块。\n请确保页面上存在该元素,并且脚本中的 CODE_BLOCK_SELECTOR ('${CODE_BLOCK_SELECTOR}') 配置正确。`);
captureButtonCode.textContent = errorTextCode; // 显示错误文本
captureButtonCode.classList.add('error'); // 按钮变红
// 恢复按钮状态
setTimeout(() => {
captureButtonCode.textContent = buttonTextStartCode;
captureButtonCode.disabled = false;
captureButtonCode.classList.remove('error');
updateStatus('');
}, exportTimeout);
return; // 退出函数
}
// 3. 获取代码块的文本内容
const codeText = codeElement.textContent;
// 检查文本内容是否为空
if (!codeText || codeText.trim().length === 0) {
updateStatus('错误: 代码块为空。');
alert('找到的代码块元素内容为空,无法提取对话。');
captureButtonCode.textContent = errorTextCode;
captureButtonCode.classList.add('error');
setTimeout(() => {
captureButtonCode.textContent = buttonTextStartCode;
captureButtonCode.disabled = false;
captureButtonCode.classList.remove('error');
updateStatus('');
}, exportTimeout);
return; // 退出函数
}
// 4. 更新状态,准备提取
updateStatus('正在提取代码块对话...');
captureButtonCode.textContent = '提取中...';
// 再次使用 setTimeout 延迟,让“提取中”状态显示出来
setTimeout(() => {
try {
// 5. 调用核心提取函数
const conversationData = extractConversationFromCodeBlock(codeText);
// 6. 调用格式化和下载函数
formatAndDownloadCodeChat(conversationData);
} catch (error) { // 捕获提取或下载过程中的意外错误
console.error('代码块导出处理过程中发生错误:', error);
updateStatus(`错误 (代码块导出): ${error.message}`);
alert(`代码块导出处理过程中发生错误: ${error.message}`);
captureButtonCode.textContent = `${errorTextCode}: 处理出错`;
captureButtonCode.classList.add('error');
// 恢复按钮状态
setTimeout(() => {
captureButtonCode.textContent = buttonTextStartCode;
captureButtonCode.disabled = false;
captureButtonCode.classList.remove('error');
updateStatus('');
}, exportTimeout);
}
}, 50); // 短暂延迟,给 UI 更新时间
}, 50); // 短暂延迟
}
// --- 脚本初始化入口 ---
// 使用 `setTimeout` 来延迟 `createUI` 函数的执行。
// 这是因为油猴脚本通常在页面 DOM 结构加载完成(DOMContentLoaded)时或之后立即执行,
// 但现代 Web 应用(如 AI Studio)可能还需要执行大量的 JavaScript 来动态渲染页面内容。
// 延迟执行可以给页面更多的时间来完成初始化渲染,从而提高脚本找到所需元素并成功注入 UI 的概率。
console.log("Google AI Studio 导出脚本 (增强版): 等待页面加载 (2.5秒)...");
setTimeout(createUI, 2500); // 设置延迟 2500 毫秒(2.5秒)后调用 `createUI` 函数来创建用户界面
})(); // IIFE 定义结束,并立即调用执行,启动脚本