// LATEST

【技术分享】ChatGPT 工作空间分析页不给你邮箱?不能导出?写个脚本把它补回来

在 ChatGPT Business 的「工作空间分析」(/admin/usage)页面,表格只给你名字 + 用量没有邮箱。可当我作为 admin 看到某个人月消息量异常、或者想顺手导出一份全员邮箱清单做盘点时,这就很难受 —— 名字一栏经常是花名、英文名、中英文混搭,根本对不上人。

我顺手写了一个油猴脚本:

  • 给表格加一列「邮箱」
  • 一键把完整成员 + 用量 + 邮箱 导出成 CSV
  • 顺手支持「只导邮箱列表」和「手动粘贴名字-邮箱映射」做兜底

整个脚本不到 800 行,纯前端、零依赖、不调用任何第三方服务,所有数据都是浏览器里本来就有的接口响应,脚本只是把它"留下来 + 拼到表格上"。

适用场景:你是 ChatGPT Business / Enterprise 的工作空间管理员,能正常打开 https://chatgpt.com/admin/usage 看到成员分析表。如果你不是 admin、看不到这个页面,脚本对你没用。

效果

image.png

装上脚本、打开 /admin/usage,右下角会浮出四个按钮:

📧 获取成员邮箱 📥 导出表格 + 邮箱 📋 导出邮箱列表 ✏️ 手动导入邮箱

表头会自动多出一列「邮箱 / Email」,按住翻页或者点导出,脚本会自动顺着 next_cursor 把所有页拉完,最后给你一个带 BOM 的 UTF-8 CSV,Excel 直接双击不乱码。

安装

  1. Chrome / Edge 装 Tampermonkey
  2. 点扩展图标 → 添加新脚本
  3. 把文末完整源码贴进去,Ctrl+S 保存
  4. 打开 https://chatgpt.com/admin/usage,右下角应该浮出四个按钮

怎么用

典型流程


  • 打开 chatgpt.com/admin/usage,等表格出来(脚本会在后台静默捕获接口)

  • 📧 获取成员邮箱,脚本从第一页开始翻所有分页,状态条会告诉你"已获取全部 N 个邮箱"

  • 📥 导出表格 + 邮箱,下载一份 chatgpt_usage_with_emails_20260511.csv

  • 万一某些行匹配不上(比如显示的是英文名、接口里却是中文名),用 ✏️ 手动导入邮箱 粘一份补丁映射,刷新表格

它是怎么做到的

这个脚本里有几个点我觉得值得单独拎出来说一下,思路通用、可以挪到其它"想给后台表格加列"的场景。

1. 在请求一发出去时就劫持,不重写

脚本用 @run-at document-start 在页面 JS 跑起来之前就接管 window.fetchXMLHttpRequest

const _fetch = window.fetch;
window.fetch = function (input, init) {
  const promise = _fetch.apply(this, arguments);
  if (url.includes('/analytics/user_list')) {
    if (!state.userListSeed) state.userListSeed = { input, init };
    promise.then(r => r.clone().json()).then(extractEmailsFromJSON);
  }
  return promise;
};

关键是 r.clone() —— 响应只能消费一次,不 clone 就会把页面自己的解析弄坏。

2. 翻页直接复用原始 Request

ChatGPT 后台请求带了一大堆 header:Authorizationchatgpt-account-idx-oai-is、各种风控字段。手动重建很容易漏。脚本的做法是把第一个 user_list 请求的 input / init 整个存下来当"种子",翻页时直接基于它造新 Request:

const req = (originalInput instanceof Request)
  ? new Request(u.toString(), originalInput)
  : new Request(u.toString(), originalInit);
坑点 1user_list 接口翻页必须同时带上 cursor query_id,漏一个服务端直接 400。脚本会读每页返回里的最新 query_id 再带到下一页。

3. 名字模糊匹配

后台表格显示的名字和接口返回的 name 不一定完全一致 —— 有人填了花名 "牧心 (Zhang Dawei)"、有人是纯拼音大写、有人前后带空格。脚本做了三层 fallback:

  1. 全小写 trim 后精确匹配
  2. 去掉括号内容再匹配
  3. 子串互含(includes

实测覆盖率 95%+,剩下的边角用「手动导入」补一下。

4. 应对 SPA 的两个监听

ChatGPT 后台是 React SPA,路由切换不走完整刷新,表格也是异步渲染的。脚本同时跑了:

  • 每 1.5s 一次的 setInterval(tick) —— 处理路由变化、按钮注入
  • MutationObserver —— 处理表格行的渐进加载、滚动追加
这两个看起来重复,其实分工:定时器负责"页面切了我得搬家",Observer 负责"DOM 在原地变,我得跟上"。任何一个单干都会漏 case。

已知限制 & 安全说明

  • 这个脚本只在你的浏览器本地运行,不上传任何数据到第三方。它做的事情等价于「你手动复制粘贴」,只是自动化。
  • 拉取的数据来自你作为 admin 已经有权限看到的接口,脚本没有任何越权操作。
  • 但导出的 CSV 里包含成员邮箱,请按公司数据合规要求保管,不要随便扔群里 / 上传到外部网盘。
  • ChatGPT 的接口路径和字段名将来可能变。如果某天脚本失灵,多半是 /analytics/user_list 改路径或返回结构改了,打开 DevTools 看一下接口名换上就行。
  • 翻页有 MAX_PAGES = 200 的上限保护,大公司如果成员超过这个量级把它调大即可。

完整源码

点击展开完整源码(约 770 行)
// ==UserScript==
// @name         ChatGPT Business 分析邮箱增强 + 导出
// @namespace    https://github.com/gpt-email-enhancer
// @version      2.0.0
// @description  在 chatgpt.com/admin/usage 工作空间分析页面为成员表格增加邮箱列,支持 CSV 导出
// @author       yantao
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  const state = {
    emailMap: {},
    emailByUserId: {},
    nameByUserId: {},
    allMembers: [],
    fetched: false,
    fetching: false,
    uiInjected: false,
    tableProcessed: false,
    accessToken: null,
    userListSeed: null,
    paginating: false,
  };

  const DEBUG = true;
  const log = (...a) => DEBUG && console.log('%c[GPT-Email]', 'color:#10a37f;font-weight:bold', ...a);

  // ---- 1. 拦截网络请求 ----
  const _fetch = window.fetch;
  window.fetch = function (input, init) {
    const url = typeof input === 'string' ? input : (input?.url || '');
    const promise = _fetch.apply(this, arguments);

    if (url.includes('/api/auth/session')) {
      promise.then(r => r.clone().json()).then(d => {
        if (d?.accessToken) state.accessToken = d.accessToken;
      }).catch(() => {});
    }

    if (url.includes('/analytics/user_list')) {
      if (!state.userListSeed) {
        state.userListSeed = { input, init };
        log('已捕获 user_list 请求种子');
      }
      promise.then(r => r.clone().json()).then(d => {
        extractEmailsFromJSON(d, url);
      }).catch(() => {});
      return promise;
    }

    if (
      url.includes('/members') || url.includes('/people') ||
      url.includes('/users') || url.includes('/workspace-user') ||
      url.includes('/admin') || url.includes('/analytics')
    ) {
      promise.then(r => r.clone().json()).then(d => {
        extractEmailsFromJSON(d, url);
      }).catch(() => {});
    }

    return promise;
  };

  async function paginateUserList(originalInput, originalInit, firstCursor, firstQueryId) {
    if (state.paginating) return;
    state.paginating = true;

    const baseUrl = typeof originalInput === 'string'
      ? originalInput : (originalInput?.url || '');

    let cursor = firstCursor;
    let queryId = firstQueryId || null;
    let pageCount = 1;
    const MAX_PAGES = 200;

    try {
      while (cursor && pageCount < MAX_PAGES) {
        pageCount++;
        const u = new URL(baseUrl, location.origin);
        u.searchParams.set('cursor', cursor);
        if (queryId) u.searchParams.set('query_id', queryId);

        const req = (originalInput instanceof Request)
          ? new Request(u.toString(), originalInput)
          : new Request(u.toString(), originalInit);

        const r = await _fetch(req);
        if (!r.ok) { log(`翻页第 ${pageCount} 页失败:${r.status}`); break; }
        const d = await r.json();
        extractEmailsFromJSON(d, u.toString());
        cursor = d?.next_cursor || null;
        if (d?.query_id) queryId = d.query_id;
      }
      log(`翻页完成,共 ${pageCount} 页`);
      showStatus(`已获取全部 ${Object.keys(state.emailMap).length} 个邮箱`, 'success');
    } catch (e) {
      showStatus(`翻页中断:${e.message}`, 'error');
    } finally {
      state.paginating = false;
    }
  }

  // ---- XHR 兜底 ----
  const _xhrOpen = XMLHttpRequest.prototype.open;
  const _xhrSend = XMLHttpRequest.prototype.send;
  XMLHttpRequest.prototype.open = function (m, url) {
    this.__url = url;
    return _xhrOpen.apply(this, arguments);
  };
  XMLHttpRequest.prototype.send = function () {
    this.addEventListener('load', function () {
      const url = this.__url || '';
      if (url.includes('/members') || url.includes('/users') || url.includes('/admin')) {
        try { extractEmailsFromJSON(JSON.parse(this.responseText), url); } catch (_) {}
      }
    });
    return _xhrSend.apply(this, arguments);
  };

  function extractEmailsFromJSON(data, source) {
    if (!data || typeof data !== 'object') return;
    const lists = [];
    for (const key of ['members', 'users', 'items', 'data', 'results', 'people', 'rows']) {
      if (Array.isArray(data[key])) lists.push(data[key]);
    }
    if (Array.isArray(data)) lists.push(data);

    let count = 0;
    for (const list of lists) {
      for (const item of list) {
        const email = item.email || item.email_address || item.user?.email || '';
        const name  = item.name || item.display_name || item.user?.name || '';
        const uid   = item.user_id || item.id || item.user?.id || '';

        if (email && email.includes('@')) {
          if (name) state.emailMap[name.toLowerCase().trim()] = email;
          if (uid)  state.emailByUserId[uid] = email;
          if (uid && name) state.nameByUserId[uid] = name;
          if (!state.allMembers.some(m => m.email === email)) {
            state.allMembers.push({ ...item });
          }
          count++;
        } else if (name && uid) {
          state.nameByUserId[uid] = name;
        }
      }
    }
    if (count > 0) {
      state.fetched = true;
      tryEnhanceTable();
    }
  }

  async function fetchMemberEmails() {
    if (state.fetching || state.paginating) return;
    if (!state.userListSeed) {
      showStatus('请先打开 /admin/usage 让页面发起一次数据请求', 'warn');
      return;
    }
    state.fetching = true;
    showStatus('正在从第一页重新拉取全部成员…');

    state.emailMap = {}; state.emailByUserId = {};
    state.nameByUserId = {}; state.allMembers = [];
    state.fetched = false; state.tableProcessed = false;
    document.querySelectorAll('[data-email-processed]').forEach(el => {
      el.removeAttribute('data-email-processed');
      el.querySelectorAll('.gpt-email-td').forEach(td => td.remove());
    });

    const { input, init } = state.userListSeed;
    const baseUrl = typeof input === 'string' ? input : (input?.url || '');
    const u = new URL(baseUrl, location.origin);
    u.searchParams.delete('cursor');
    u.searchParams.delete('query_id');

    try {
      const req = (input instanceof Request)
        ? new Request(u.toString(), input)
        : new Request(u.toString(), init);
      const r = await _fetch(req);
      if (!r.ok) { showStatus(`首页请求失败:${r.status}`, 'error'); return; }
      const d = await r.json();
      extractEmailsFromJSON(d, u.toString());
      if (d?.next_cursor) {
        await paginateUserList(input, init, d.next_cursor, d.query_id);
      } else {
        showStatus(`已获取全部 ${Object.keys(state.emailMap).length} 个邮箱`, 'success');
      }
    } finally {
      state.fetching = false;
    }
  }

  // ---- 表格增强、UI 注入、CSV 导出、手动导入弹窗 ----
  // 完整逻辑见原文件,限于篇幅这里省略,
  // 接下来的章节是 findUsageTable / tryEnhanceTable /
  // injectToolbar / exportTableCSV / openManualInput 等函数,
  // 行为和文中描述完全一致。

  function init() {
    /* 启动入口:injectStyles + setInterval(tick) + MutationObserver */
  }
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else { init(); }
})();
上面是节选版骨架,完整可用源码请从原始文件 Untitled-1.js 拷贝,或在评论区找我要。

写在最后

这个小脚本的真正意义不在"加一列邮箱",而在于演示一个通用解法:当你拿不到后台没有暴露的字段时,先别急着写爬虫,它的接口本来就在你浏览器里跑 —— 劫持 fetch + 复用原始 Request,比任何重新组装请求的方案都稳。

如果你也在做类似的内部运营/管理后台增强,思路完全可以照搬。

有任何 bug 或者改进建议,欢迎评论区交流。如果你的公司 ChatGPT 用量盘点也踩过类似的"看不到邮箱"的坑,记得点个赞让更多 admin 看到 🙌

还没有评论

欢迎留下你的观点,保持交流的清晰和友好。

写下评论