在 ChatGPT Business 的「工作空间分析」(/admin/usage)页面,表格只给你名字 + 用量,没有邮箱。可当我作为 admin 看到某个人月消息量异常、或者想顺手导出一份全员邮箱清单做盘点时,这就很难受 —— 名字一栏经常是花名、英文名、中英文混搭,根本对不上人。
我顺手写了一个油猴脚本:
- 给表格加一列「邮箱」
- 一键把完整成员 + 用量 + 邮箱 导出成 CSV
- 顺手支持「只导邮箱列表」和「手动粘贴名字-邮箱映射」做兜底
整个脚本不到 800 行,纯前端、零依赖、不调用任何第三方服务,所有数据都是浏览器里本来就有的接口响应,脚本只是把它"留下来 + 拼到表格上"。
https://chatgpt.com/admin/usage 看到成员分析表。如果你不是 admin、看不到这个页面,脚本对你没用。效果

装上脚本、打开 /admin/usage,右下角会浮出四个按钮:
表头会自动多出一列「邮箱 / Email」,按住翻页或者点导出,脚本会自动顺着 next_cursor 把所有页拉完,最后给你一个带 BOM 的 UTF-8 CSV,Excel 直接双击不乱码。
安装
- Chrome / Edge 装 Tampermonkey
- 点扩展图标 → 添加新脚本
- 把文末完整源码贴进去,Ctrl+S 保存
- 打开
https://chatgpt.com/admin/usage,右下角应该浮出四个按钮
怎么用
典型流程
- ① 打开
chatgpt.com/admin/usage,等表格出来(脚本会在后台静默捕获接口) - ② 点 📧 获取成员邮箱,脚本从第一页开始翻所有分页,状态条会告诉你"已获取全部 N 个邮箱"
- ③ 点 📥 导出表格 + 邮箱,下载一份
chatgpt_usage_with_emails_20260511.csv - ④ 万一某些行匹配不上(比如显示的是英文名、接口里却是中文名),用 ✏️ 手动导入邮箱 粘一份补丁映射,刷新表格
它是怎么做到的
这个脚本里有几个点我觉得值得单独拎出来说一下,思路通用、可以挪到其它"想给后台表格加列"的场景。
1. 在请求一发出去时就劫持,不重写
脚本用 @run-at document-start 在页面 JS 跑起来之前就接管 window.fetch 和 XMLHttpRequest:
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:Authorization、chatgpt-account-id、x-oai-is、各种风控字段。手动重建很容易漏。脚本的做法是把第一个 user_list 请求的 input / init 整个存下来当"种子",翻页时直接基于它造新 Request:
const req = (originalInput instanceof Request)
? new Request(u.toString(), originalInput)
: new Request(u.toString(), originalInit);user_list 接口翻页必须同时带上 cursor 和 query_id,漏一个服务端直接 400。脚本会读每页返回里的最新 query_id 再带到下一页。3. 名字模糊匹配
后台表格显示的名字和接口返回的 name 不一定完全一致 —— 有人填了花名 "牧心 (Zhang Dawei)"、有人是纯拼音大写、有人前后带空格。脚本做了三层 fallback:
- 全小写 trim 后精确匹配
- 去掉括号内容再匹配
- 子串互含(
includes)
实测覆盖率 95%+,剩下的边角用「手动导入」补一下。
4. 应对 SPA 的两个监听
ChatGPT 后台是 React SPA,路由切换不走完整刷新,表格也是异步渲染的。脚本同时跑了:
- 每 1.5s 一次的
setInterval(tick)—— 处理路由变化、按钮注入 MutationObserver—— 处理表格行的渐进加载、滚动追加
已知限制 & 安全说明
- 这个脚本只在你的浏览器本地运行,不上传任何数据到第三方。它做的事情等价于「你手动复制粘贴」,只是自动化。
- 拉取的数据来自你作为 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,比任何重新组装请求的方案都稳。
如果你也在做类似的内部运营/管理后台增强,思路完全可以照搬。
还没有评论
欢迎留下你的观点,保持交流的清晰和友好。
写下评论