---
title: "Google AI Studio 完整复制与链接净化油猴脚本：一键保留引用来源并还原真实 URL"
url: "https://afengblog.com/blog/google-ai-studio-clean-copy-markdown.html"
author: "AFeng"
published: "2026-10-30T11:53:42.849Z"
updated: "2026-10-30T11:56:42.794Z"
category: "工具与效率"
tags: ["Google AI Studio","Tampermonkey","Userscript","Markdown"]
reading_time: "24 分钟阅读"
description: "针对 Google AI Studio 原生复制丢失 Grounding 引用来源及强制包裹 vertexaisearch 加密重定向链接的痛点，本文分享开源油猴脚本 Google AI Studio Clean Copy Markdown，实现一键复制完整正文、角标与来源列表并自动还原真实干净网址。"
---

# Google AI Studio 完整复制与链接净化油猴脚本：一键保留引用来源并还原真实 URL

> Google AI Studio 原生复制会丢失 Grounding 引用链接与底部 Sources，还会强制套上 vertexaisearch 加密重定向长链。本文分享开源油猴脚本 Google AI Studio Clean Copy Markdown，支持一键复制完整 Markdown（正文 + 文内引用 + 来源列表）并自动将重定向还原为干净的真实 URL。

## TL;DR（太长不看）

- Google AI Studio 原生 Copy as markdown 会丢掉联网搜索（Grounding）的文内引用角标和底部 Sources 来源列表，且外部链接均被 vertexaisearch 服务端 302 Token 包裹
- 开源油猴脚本 Google AI Studio Clean Copy Markdown 在消息工具栏增加绿色一键复制按钮，一键提取完整 Markdown（正文 + 文内引用 + 底部来源）
- 内置双层重定向拆包与 HTTP 302 Location 响应头解析，配合鼠标悬停预热与 24 小时本地缓存，自动还原干净真实 URL 并清洗 utm_* 等追踪参数

在 Google AI Studio 里开启联网搜索（Grounding with Google Search）查资料或写技术调研时，官方自带的 **Copy as markdown** 有两个非常影响效率的硬伤：一是复制出的文本会直接丢掉文内引用角标的网址和底部的 **Sources** 来源列表；二是所有外部链接都被强制包裹了 Google 的重定向地址（尤其是 `vertexaisearch.cloud.google.com/grounding-api-redirect/...` 这种服务端加密 Token），普通去跟踪扩展根本洗不掉。

为了彻底解决这个问题，我写了一个开源油猴脚本 **[Google AI Studio 完整复制 Markdown（Google AI Studio Clean Copy Markdown）](https://greasyfork.org/zh-CN/scripts/602979-google-ai-studio-clean-copy-markdown)**。它在每条模型回复的工具栏注入一个绿色的 `markdown_copy` 按钮，点击即可一键复制**正文 + 文内引用 `[[1](真实网址)]` + 底部 `## 来源` 列表**，并在后台静默解开 `google.com/url` 与 `vertexaisearch` 重定向，还原出干净的真实 URL。

![Google AI Studio 链接净化与完整复制](https://afengblog.com/uploads/2026/10/becc367aeedadb811e253f93.webp)

## 为什么原生复制和现有插件不够用

在动手写脚本之前，我先把 Greasy Fork 和 Chrome 扩展商店里相关的工具测了一遍。首先要搞清楚 Google AI Studio 里的两类重定向链接：

1. **常规跳转重定向**：`https://www.google.com/url?sa=E&q=真实网址...`，真实地址明文藏在查询参数 `q` 或 `url` 里。
2. **联网搜索加密重定向**：`https://vertexaisearch.cloud.google.com/grounding-api-redirect/AUZIYQ...`，这是 Vertex AI Grounding 生成的服务端加密 Token，URL 字符串里完全不包含目标网址，必须实际发起网络请求、读取 HTTP `302 Location` 响应头才能拿到真实地址。

对比目前社区里已有的几款脚本和扩展，各自的局限都很明显：

| 工具名称 | 类型 | 主要功能 | 核心局限 |
|---|---|---|---|
| **AI Studio Copy as Markdown + Sources**（作者：mekineer） | 油猴脚本 | 把回答正文连同底部 Sources 一起复制为 Markdown | 只负责把链接抓出来，没有做重定向清洗，复制出来的依然是冗长的 Google 重定向链接 |
| **Google AI Studio \| Quick Actions Toolbar**（作者：Piknockyou） | 油猴脚本 | 把三个点菜单里的 Copy as Markdown 和 Delete 提取到悬浮工具栏 | 只是调用官方原生复制，同样会丢失 Grounding 引用链接和底部来源 |
| **AI Chat Exporter / AI Chat to Markdown** | 浏览器扩展 / 脚本 | 将整段对话导出为 Markdown、DOCX、JSON 或 TXT | 适合整篇对话归档，不适合日常单条回复快速复制，同样无法清洗 `vertexaisearch` 加密长链 |
| **ClearURLs** | 浏览器扩展 | 自动剥离常见跟踪参数与 `google.com/url?q=` 明文跳转 | 无法解析 `vertexaisearch.cloud.google.com/grounding-api-redirect/` 的服务端 302 跳转 |

既然没有现成工具同时把「完整复制」和「加密重定向清洗」做好，直接自己写一个反而是最干净的方案。

## 脚本核心设计与实现细节

脚本围绕「不打断阅读体验」和「拿到最干净的 Markdown」做了以下几处关键设计：

![Google AI Studio 消息工具栏中的绿色复制 Markdown 按钮](https://afengblog.com/uploads/2026/10/97f45611134ba6d4a6aa4687.webp)

### 1. 完整提取正文、文内角标与底部来源

脚本直接解析模型回复节点，将标题、多级列表、引用块、行内代码、围栏代码块、文内引用角标 `[[1](https://...)]` 以及底部的 **Sources** 来源列表一并提取为标准 Markdown，同时自动过滤思考过程（`ms-thought-chunk`）与免责声明等无关内容。

### 2. 双层重定向拆包与 HTTP 302 头解析

针对两类不同的包裹链接，脚本采用分级处理策略：

- **`google.com/url?q=` 明文跳转**：本地通过 `URLSearchParams` 提取 `q` 或 `url` 参数，支持最多 3 层嵌套拆包。如果拆出来的内层地址仍然是 `vertexaisearch` 链接，自动转入下一级解析。
- **`vertexaisearch` 加密重定向**：利用油猴的 `GM_xmlhttpRequest`（开启 `anonymous: true` 不带 Cookie）绕过页面 CORS 限制，优先发送 `HEAD` 请求（失败时回退 `GET`），直接从响应头中提取 `Location` 或 `finalUrl`，无需完整下载目标网站正文。
- **跟踪参数清洗**：拿到最终 URL 后，自动剥离 `utm_*`、`spm`、`fbclid`、`gclid`、`gbraid`、`wbraid`、`yclid`、`mc_eid` 等常见追踪参数。

### 3. 悬停预热与 24 小时持久化缓存

一条开启了 Grounding 的长回复往往包含十几个引用链接。为了避免点击复制时等待网络请求，脚本加入了两层加速机制：

- **悬停与首屏预热（`prewarmTurn`）**：页面加载完成后或鼠标指针悬停（`pointerover`）到某条模型回复上时，后台以最大 8 并发（`CONCURRENCY = 8`）静默预解析该条回复中的所有重定向链接。等你真正点击绿色复制按钮时，绝大部分链接已经命中缓存，能够瞬间完成复制。
- **双层缓存（内存 + `GM_setValue`）**：解析结果同时写入内存 `Map` 与油猴本地存储，默认有效期 24 小时，容量上限 3000 条并自动按过期时间淘汰旧记录，刷新页面后依然秒出结果。

### 4. 浏览器语言自适应与最小权限范围

- **中英文自动适配**：按钮悬浮提示、右下角状态 Toast（`正在复制…` / `已复制`）以及追加在文末的来源章节标题（`## 来源` 或 `## Sources`），统一根据浏览器语言（`navigator.languages` 是否包含 `zh*`）自动切换，不受 AI Studio 界面语言限制。
- **精准匹配对话页**：`@match` 严格收敛在 `https://aistudio.google.com/prompts/*` 与多账号路径 `https://aistudio.google.com/u/*/prompts/*`，不会在 API Key 管理、Library 或文档页面注入任何无用代码。

## 安装方式与开源地址

脚本已开源并同步发布至 Greasy Fork 与 GitHub，遵循 MIT 许可证：

- **Greasy Fork 一键安装**：[Google AI Studio Clean Copy Markdown](https://greasyfork.org/zh-CN/scripts/602979-google-ai-studio-clean-copy-markdown)
- **GitHub 仓库主页**：[magiccodelab/aistudio-clean-copy](https://github.com/magiccodelab/aistudio-clean-copy)
- **Raw 脚本直装地址**：[aistudio-clean-copy.user.js](https://raw.githubusercontent.com/magiccodelab/aistudio-clean-copy/main/aistudio-clean-copy.user.js)

### 使用步骤

1. 浏览器安装 [Tampermonkey](https://www.tampermonkey.net/) 或 Violentmonkey 扩展。
2. 点击上方 Greasy Fork 或 Raw 链接确认安装脚本（也可以直接复制下方完整源码，在油猴面板中「添加新脚本」粘贴保存）。
3. 打开任意 `https://aistudio.google.com/prompts/...` 对话页，将鼠标悬停在模型回复右上角的工具栏上，点击绿色的 **`复制 Markdown`** 图标。
4. 首次点击时，如果油猴弹出跨域请求确认窗口，点击 **「始终允许域名（Always allow domain）」** 放行 `vertexaisearch.cloud.google.com` 即可。

## 完整油猴脚本源码

如果你倾向于直接审阅代码或手动添加脚本，以下是当前 `v1.2.3` 版本的完整源码：

```javascript
// ==UserScript==
// @name              Google AI Studio Clean Copy Markdown
// @name:en           Google AI Studio Clean Copy Markdown
// @name:zh           Google AI Studio 完整复制 Markdown
// @name:zh-CN        Google AI Studio 完整复制 Markdown
// @namespace         https://github.com/magiccodelab/aistudio-clean-copy
// @version           1.2.3
// @description       One-click copy of a complete Google AI Studio reply as Markdown: body, citations, Sources. Unwraps google.com/url and vertexaisearch grounding redirects.
// @description:en    One-click copy of a complete Google AI Studio reply as Markdown: body, citations, Sources. Unwraps google.com/url and vertexaisearch grounding redirects.
// @description:zh    一键复制 Google AI Studio 完整回复为 Markdown：正文、文内引用、底部 Sources；还原 google.com/url 与 vertexaisearch 重定向。
// @description:zh-CN 一键复制 Google AI Studio 完整回复为 Markdown：正文、文内引用、底部 Sources；还原 google.com/url 与 vertexaisearch 重定向。
// @author            afeng (magiccodelab)
// @license           MIT
// @homepageURL       https://github.com/magiccodelab/aistudio-clean-copy
// @supportURL        https://github.com/magiccodelab/aistudio-clean-copy/issues
// @downloadURL       https://raw.githubusercontent.com/magiccodelab/aistudio-clean-copy/main/aistudio-clean-copy.user.js
// @updateURL         https://raw.githubusercontent.com/magiccodelab/aistudio-clean-copy/main/aistudio-clean-copy.user.js
// @match             https://aistudio.google.com/prompts/*
// @match             https://aistudio.google.com/u/*/prompts/*
// @run-at            document-idle
// @grant             GM_xmlhttpRequest
// @grant             GM_setClipboard
// @grant             GM_setValue
// @grant             GM_getValue
// @grant             GM_addStyle
// @connect           vertexaisearch.cloud.google.com
// @connect           *
// ==/UserScript==

(() => {
  'use strict';
  if (window.top !== window.self) return;
  if (!/^\/(?:u\/\d+\/)?prompts\//.test(location.pathname)) return;

  const W = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
  const CONCURRENCY = 8;
  const TIMEOUT_MS = 10000;
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
  const CACHE_KEY = 'aistudio_clean_copy_cache_v1';
  const TOOLBAR_SEL = '.actions.hover-or-edit';
  const BTN_CLASS = 'ais-clean-copy-btn';
  const SKIP_SEL = 'button, svg, script, style, textarea, input, ms-chat-turn-options, ms-grounding-sources, ms-thought-chunk, ms-search-entry-point, ms-hallucinations-disclaimer, mat-expansion-panel-header, .actions';

  const VERTEX_BODY = String.raw`https?:\/\/vertexaisearch\.cloud\.google\.com\/grounding-api-redirect\/[A-Za-z0-9_\-=%]+`;
  const GOOGLE_BODY = String.raw`https?:\/\/(?:[a-z0-9-]+\.)*google\.[a-z]{2,3}(?:\.[a-z]{2})?\/url\?[A-Za-z0-9\-._~:/?#@!$&*+,;=%]+`;
  const reVertex = new RegExp('^' + VERTEX_BODY + '$', 'i');
  const reGoogle = new RegExp('^' + GOOGLE_BODY + '$', 'i');
  const reURL = new RegExp(GOOGLE_BODY + '|' + VERTEX_BODY, 'gi');
  const TRACKING_KEYS = /^(utm_|spm$|fbclid$|gclid$|gbraid$|wbraid$|yclid$|mc_eid$|clid$|_hs)/i;

  const mem = new Map();
  const inflight = new Map();
  let persist = {};
  try { persist = GM_getValue(CACHE_KEY, {}) || {}; } catch { persist = {}; }

  function sleep(ms) {
    return new Promise((r) => setTimeout(r, ms));
  }

  function uniq(arr) {
    return [...new Set((arr || []).filter(Boolean))];
  }

  function now() {
    return Date.now();
  }

  function cacheGet(k) {
    const m = mem.get(k);
    if (m && m.exp > now()) return m.dst;
    const p = persist[k];
    if (p && p.exp > now() && p.dst) {
      mem.set(k, p);
      return p.dst;
    }
    return '';
  }

  function cachePut(k, dst) {
    const e = { dst, exp: now() + CACHE_TTL_MS };
    mem.set(k, e);
    persist[k] = e;
    const keys = Object.keys(persist);
    if (keys.length > 3000) {
      keys.sort((a, b) => (persist[a].exp || 0) - (persist[b].exp || 0));
      for (const old of keys.slice(0, keys.length - 2500)) delete persist[old];
    }
    try { GM_setValue(CACHE_KEY, persist); } catch {}
  }

  function canon(m) {
    m = String(m || '').replace(/\\\//g, '/');
    if (m.length > 4 && m.slice(0, 5).toLowerCase() === 'http:') m = 'https:' + m.slice(5);
    return m;
  }

  function splitMatch(m) {
    if (reGoogle.test(m)) {
      const k = m.replace(/[.,;:!?\\]+$/, '');
      return { key: canon(k), rest: m.slice(k.length) };
    }
    return { key: canon(m), rest: '' };
  }

  function unwrapGoogle(u) {
    let cur = u;
    for (let i = 0; i < 3; i++) {
      if (!reGoogle.test(cur)) return cur;
      const s = cur.replace(/&amp;/g, '&').replace(/\\u0026/g, '&');
      let p;
      try { p = new URL(s); } catch { throw new Error('Google 跳转链接解析失败'); }
      const t = p.searchParams.get('q') || p.searchParams.get('url') || '';
      let tp;
      try { tp = new URL(t); } catch { tp = null; }
      if (!t || !tp || !tp.host || (tp.protocol !== 'http:' && tp.protocol !== 'https:')) {
        throw new Error('Google 跳转链接缺少有效的目标地址');
      }
      cur = t;
    }
    return cur;
  }

  function stripTracking(urlStr) {
    try {
      const u = new URL(urlStr);
      [...u.searchParams.keys()].forEach((k) => {
        if (TRACKING_KEYS.test(k)) u.searchParams.delete(k);
      });
      u.hash = '';
      return u.toString();
    } catch {
      return urlStr;
    }
  }

  function parseLocation(headers) {
    const m = String(headers || '').match(/^location:\s*(.+)$/im);
    return m ? m[1].trim() : '';
  }

  function gmRequest(opts) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        anonymous: true,
        timeout: TIMEOUT_MS,
        ...opts,
        onload: resolve,
        onerror: () => reject(new Error('net')),
        ontimeout: () => reject(new Error('timeout')),
      });
    });
  }

  async function resolveVertex(u) {
    let last = '';
    for (let i = 0; i <= 2; i++) {
      if (i) await sleep(i * 200);
      try {
        const r = await gmRequest({ method: i === 2 ? 'GET' : 'HEAD', url: u });
        const loc = parseLocation(r.responseHeaders);
        const code = r.status || r.statusCode || 0;
        if (loc && code >= 300 && code < 400) return loc;
        if (r.finalUrl && !/grounding-api-redirect/i.test(r.finalUrl) && r.finalUrl !== u) {
          return r.finalUrl;
        }
        last = 'HTTP ' + code + ' 无 Location';
        if (code >= 400 && code < 500 && code !== 429) break;
      } catch (e) {
        last = e && e.message ? e.message : 'net';
      }
    }
    throw new Error(last || '还原失败');
  }

  async function resolveOne(raw) {
    const { key } = splitMatch(raw);
    const cached = cacheGet(key);
    if (cached) return cached;
    if (inflight.has(key)) return inflight.get(key);

    const job = (async () => {
      let u = key;
      if (reGoogle.test(u)) {
        let inner;
        try { inner = unwrapGoogle(u); } catch { return ''; }
        if (!reVertex.test(inner)) {
          const dst = stripTracking(inner);
          cachePut(key, dst);
          return dst;
        }
        u = canon(inner);
        const hit = cacheGet(u);
        if (hit) {
          cachePut(key, hit);
          return hit;
        }
      }
      if (!reVertex.test(u)) return '';
      try {
        let dest = await resolveVertex(u);
        try { new URL(dest); } catch { return ''; }
        if (reGoogle.test(dest)) {
          try { dest = unwrapGoogle(dest); } catch {}
        }
        dest = stripTracking(dest);
        cachePut(u, dest);
        cachePut(key, dest);
        return dest;
      } catch {
        return '';
      }
    })();

    inflight.set(key, job);
    try {
      return await job;
    } finally {
      inflight.delete(key);
    }
  }

  async function resolveText(text) {
    const src = String(text || '');
    const matches = src.match(reURL) || [];
    const keys = [];
    const idx = new Map();
    for (const m of matches) {
      const k = splitMatch(m).key;
      if (!idx.has(k)) {
        idx.set(k, keys.length);
        keys.push(k);
      }
    }
    const dests = new Array(keys.length).fill('');
    let i = 0;
    async function worker() {
      while (i < keys.length) {
        const n = i++;
        dests[n] = await resolveOne(keys[n]);
      }
    }
    await Promise.all(Array.from({ length: Math.min(CONCURRENCY, keys.length || 1) }, worker));

    reURL.lastIndex = 0;
    return src.replace(reURL, (m) => {
      const { key, rest } = splitMatch(m);
      const d = dests[idx.get(key)];
      return d ? d + rest : m;
    });
  }

  function qsa(root, sel) {
    return [...(root || document).querySelectorAll(sel)];
  }

  function isModelTurn(turn) {
    return !!turn && (
      turn.querySelector('ms-grounding-sources') ||
      turn.querySelector('button[aria-label="Good response"],button[aria-label="Bad response"]')
    );
  }

  function tidyMd(s) {
    return String(s || '')
      .replace(/\r\n/g, '\n')
      .replace(/\u00a0/g, ' ')
      .replace(/[ \t]+\n/g, '\n')
      .replace(/\n{3,}/g, '\n\n')
      .replace(/[ \t]+$/gm, '')
      .replace(/^\n+/, '')
      .replace(/\s+$/, '');
  }

  function kids(node, ctx) {
    let out = '';
    for (const child of node.childNodes) out += ser(child, ctx);
    return out;
  }

  function ser(node, ctx) {
    ctx = ctx || {};
    if (node.nodeType === 3) return node.nodeValue.replace(/\u00a0/g, ' ');
    if (node.nodeType !== 1) return '';
    if (node.matches && node.matches(SKIP_SEL)) return '';

    const tag = node.tagName;

    if (tag === 'MS-CODE-BLOCK') {
      const lang = node.getAttribute('data-test-language') || '';
      const pre = node.querySelector('pre');
      const code = String(pre ? pre.textContent : '').replace(/\n$/, '');
      return '\n\n```' + lang + '\n' + code + '\n```\n\n';
    }
    if (tag === 'PRE') {
      const code = String(node.textContent || '').replace(/\n$/, '');
      return '\n\n```\n' + code + '\n```\n\n';
    }
    if (tag === 'BR') return '\n';
    if (tag === 'HR') return '\n\n---\n\n';
    if (tag === 'STRONG' || tag === 'B') return '**' + kids(node, { inline: true }) + '**';
    if (tag === 'EM' || tag === 'I') return '*' + kids(node, { inline: true }) + '*';
    if (node.classList && node.classList.contains('inline-code')) {
      return '`' + String(node.textContent || '').replace(/`/g, '\\`') + '`';
    }
    if (tag === 'A') {
      const href = node.href || node.getAttribute('href') || '';
      const text = String(node.textContent || '').replace(/\s+/g, ' ').trim();
      if (!href || href === location.href) return kids(node, { inline: true });
      return '[' + (text || '链接') + '](' + href + ')';
    }
    if (/^H[1-6]$/.test(tag)) {
      return '\n\n' + '#'.repeat(+tag[1]) + ' ' + kids(node, { inline: true }).trim() + '\n\n';
    }
    if (tag === 'P') {
      const inner = kids(node, { inline: true });
      if (!inner.trim()) return '';
      return ctx.inLi ? inner + '\n' : inner + '\n\n';
    }
    if (tag === 'LI') {
      const inner = kids(node, { inLi: true }).replace(/\s+$/, '');
      const parent = node.parentElement;
      const lis = parent ? [...parent.children].filter((c) => c.tagName === 'LI') : [];
      const bullet = parent && parent.tagName === 'OL' ? (lis.indexOf(node) + 1) + '. ' : '- ';
      const pad = ' '.repeat(bullet.length);
      const body = inner.split('\n').map((ln, i) => {
        if (i === 0) return bullet + ln;
        return ln ? pad + ln : '';
      }).join('\n');
      return body + '\n';
    }
    if (tag === 'UL' || tag === 'OL') return '\n' + kids(node, { inLi: true }) + '\n';
    if (tag === 'BLOCKQUOTE') {
      const inner = kids(node, { inLi: true }).trim();
      return '\n\n' + inner.split('\n').map((l) => '> ' + l).join('\n') + '\n\n';
    }
    return kids(node, ctx);
  }

  function readRawBox(turn) {
    const box = turn && turn.querySelector('.very-large-text-container');
    return box ? tidyMd(box.textContent) : '';
  }

  function serializeTurn(turn) {
    const chunk = turn.querySelector('ms-text-chunk') || turn;
    return tidyMd(ser(chunk));
  }

  function getTurnMarkdown(turn) {
    return readRawBox(turn) || serializeTurn(turn);
  }

  function sourceItems(turn) {
    const items = [];
    const seen = new Set();
    for (const a of qsa(turn, 'ms-grounding-sources a[href]')) {
      const href = a.href;
      if (!href || seen.has(href)) continue;
      seen.add(href);
      items.push({
        title: String(a.textContent || '').replace(/\s+/g, ' ').trim() || href,
        href,
      });
    }
    return items;
  }

  async function appendSources(md, turn) {
    const items = sourceItems(turn);
    if (!items.length || /\n## (?:Sources|来源)\s*\n/i.test(md)) return md;
    const lines = [];
    for (let i = 0; i < items.length; i++) {
      const url = (await resolveOne(items[i].href)) || items[i].href;
      const title = items[i].title.replace(/\[/g, '\\[').replace(/\]/g, '\\]');
      lines.push((i + 1) + '. [' + title + '](' + url + ')');
    }
    return md.replace(/\s+$/, '') + '\n\n## ' + t.sources + '\n\n' + lines.join('\n') + '\n';
  }

  function browserLangs() {
    const list = [];
    try {
      if (Array.isArray(navigator.languages)) list.push(...navigator.languages);
    } catch {}
    try {
      if (navigator.language) list.push(navigator.language);
    } catch {}
    try {
      if (W.navigator && W.navigator.language) list.push(W.navigator.language);
    } catch {}
    return list.map((s) => String(s || '').toLowerCase());
  }

  function isZhBrowser() {
    return browserLangs().some((l) => l === 'zh' || l.startsWith('zh-'));
  }

  const I18N = {
    en: {
      btn: 'Copy Markdown',
      copying: 'Copying…',
      copied: 'Copied',
      empty: 'Nothing to copy',
      fail: 'Copy failed',
      sources: 'Sources',
    },
    zh: {
      btn: '复制 Markdown',
      copying: '正在复制…',
      copied: '已复制',
      empty: '没有可复制的内容',
      fail: '复制失败',
      sources: '来源',
    },
  };
  const t = I18N[isZhBrowser() ? 'zh' : 'en'];

  function copyText(text) {
    try { GM_setClipboard(String(text), 'text'); } catch {}
    try {
      const clip = W.navigator && W.navigator.clipboard;
      if (clip && clip.writeText) return clip.writeText(String(text));
    } catch {}
    return Promise.resolve();
  }

  function toast(text, kind) {
    let el = document.getElementById('ais-clean-copy-toast');
    if (!el) {
      el = document.createElement('div');
      el.id = 'ais-clean-copy-toast';
      document.documentElement.appendChild(el);
    }
    el.textContent = text;
    el.dataset.kind = kind || 'ok';
    el.dataset.show = '1';
    clearTimeout(toast._t);
    toast._t = setTimeout(() => { el.dataset.show = '0'; }, 1600);
  }

  function setBtnState(btn, icon, busy) {
    const span = btn.querySelector('.material-symbols-outlined');
    if (span) span.textContent = icon;
    btn.classList.toggle('ais-busy', !!busy);
  }

  async function copyTurn(turn, btn) {
    setBtnState(btn, 'hourglass_empty', true);
    toast(t.copying, 'busy');
    try {
      let md = getTurnMarkdown(turn);
      if (!md.trim()) throw new Error(t.empty);
      md = await resolveText(md);
      md = await appendSources(md, turn);
      await copyText(md);
      setBtnState(btn, 'check', false);
      toast(t.copied);
    } catch (e) {
      console.warn('[ais-clean-copy]', e);
      setBtnState(btn, 'error', false);
      toast(t.fail, 'err');
    }
    setTimeout(() => setBtnState(btn, 'markdown_copy', false), 1200);
  }

  function createButton() {
    const btn = document.createElement('button');
    btn.type = 'button';
    btn.className = 'mat-mdc-tooltip-trigger ms-button-borderless ms-button-icon ' + BTN_CLASS;
    btn.setAttribute('ms-button', '');
    btn.setAttribute('variant', 'icon-borderless');
    btn.setAttribute('aria-label', t.btn);
    btn.title = t.btn;
    const icon = document.createElement('span');
    icon.className = 'material-symbols-outlined notranslate ms-button-icon-symbol';
    icon.setAttribute('aria-hidden', 'true');
    icon.textContent = 'markdown_copy';
    btn.appendChild(icon);
    return btn;
  }

  function enhanceToolbar(toolbar) {
    if (!toolbar || toolbar.querySelector('.' + BTN_CLASS)) return;
    const turn = toolbar.closest('ms-chat-turn');
    if (!isModelTurn(turn)) return;
    const menu = toolbar.querySelector('ms-chat-turn-options');
    const btn = createButton();
    btn.addEventListener('click', (e) => {
      e.preventDefault();
      e.stopPropagation();
      copyTurn(turn, btn);
    });
    if (menu) toolbar.insertBefore(btn, menu);
    else toolbar.appendChild(btn);
  }

  function scan(root) {
    const base = root && root.querySelectorAll ? root : document;
    if (root && root.matches && root.matches(TOOLBAR_SEL)) enhanceToolbar(root);
    qsa(base, TOOLBAR_SEL).forEach(enhanceToolbar);
  }

  function prewarmTurn(turn) {
    if (!isModelTurn(turn)) return;
    const hrefs = uniq(qsa(turn, 'a[href]').map((a) => a.href).filter((h) => {
      reURL.lastIndex = 0;
      return reURL.test(h);
    }));
    hrefs.forEach((h) => { resolveOne(h); });
  }

  GM_addStyle(`
    .${BTN_CLASS} { margin: 0 2px; cursor: pointer; }
    .${BTN_CLASS} span.material-symbols-outlined { color: #34a853 !important; }
    .${BTN_CLASS}.ais-busy { opacity: .6; pointer-events: none; }
    #ais-clean-copy-toast {
      position: fixed; right: 20px; bottom: 20px; z-index: 2147483647;
      padding: 8px 12px; border-radius: 8px;
      font: 13px/1.4 "Google Sans", system-ui, sans-serif;
      background: #1f1f1f; color: #fff;
      opacity: 0; pointer-events: none;
      transform: translateY(6px);
      transition: opacity .12s ease, transform .12s ease;
    }
    #ais-clean-copy-toast[data-show="1"] { opacity: .94; transform: none; }
    #ais-clean-copy-toast[data-kind="err"] { background: #b3261e; }
    #ais-clean-copy-toast[data-kind="busy"] { background: #3c4043; }
  `);

  scan(document);
  const mo = new MutationObserver((muts) => {
    for (const m of muts) {
      for (const n of m.addedNodes) {
        if (n.nodeType === 1) scan(n);
      }
    }
  });
  mo.observe(document.documentElement, { childList: true, subtree: true });

  document.addEventListener('pointerover', (e) => {
    const turn = e.target && e.target.closest && e.target.closest('ms-chat-turn');
    if (turn) prewarmTurn(turn);
  }, true);

  setTimeout(() => {
    qsa(document, 'ms-chat-turn').forEach(prewarmTurn);
  }, 400);
})();
```

标签：[Google AI Studio](https://afengblog.com/tag/google-ai-studio)、[Tampermonkey](https://afengblog.com/tag/tampermonkey)、[Userscript](https://afengblog.com/tag/userscript)、[Markdown](https://afengblog.com/tag/markdown)

---

原文：https://afengblog.com/blog/google-ai-studio-clean-copy-markdown.html

其他页面同样支持在 URL 后追加 `.md` 获取 Markdown，站点索引见 https://afengblog.com/llms.txt
