自动捕获 Authorization/fingerprint/x-trace-id 并用 GM_xmlhttpRequest 请求 CP 出款接口 + 充值统计(简洁面板,可收起/展开)
// ==UserScript==
// @name PD-CP出款&充值统计
// @namespace http://tampermonkey.net/
// @version 0.5.2
// @description 自动捕获 Authorization/fingerprint/x-trace-id 并用 GM_xmlhttpRequest 请求 CP 出款接口 + 充值统计(简洁面板,可收起/展开)
// @author Cisco
// @match https://admin2-397-c1f073.j-d-0-q.com/*
// @grant GM_notification
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @connect api2.b-4-s-f.com
// @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const NS = 'cpWithdrawGM';
const CAPTURE_KEY = 'capturedHeaders';
const CONFIG = {
apiBaseUrl: 'https://api2.b-4-s-f.com',
withdrawalPath: '/api/backend/trpc/withdrawal.allReviewedList',
payPath: '/api/backend/trpc/payRecord.list',
tenantId: 8801769,
withdrawalChannels: 2561,
pageSize: 50,
refreshInterval: 15000,
amountIsCents: true,
maxPagesParallel: 6,
cacheTTL: 10000
};
GM_addStyle(`
.${NS}-panel { position: fixed; top:20px; right:20px; width:320px; z-index:99999; background:#fff; border:1px solid #ddd; border-radius:6px; padding:12px; box-shadow:0 6px 18px rgba(0,0,0,0.08); font-family:Arial, sans-serif; transition: all 0.25s ease; }
.${NS}-panel.${NS}_collapsed { width:44px; height:44px; padding:6px; overflow:hidden; }
.${NS}-toggle { position:absolute; top:8px; right:8px; width:30px; height:30px; border-radius:50%; border:none; background:#f0f0f0; cursor:pointer; display:flex; align-items:center; justify-content:center; }
.${NS}-header { color:#409EFF; font-weight:700; font-size:15px; margin-bottom:10px; }
.${NS}-stat-row { display:flex; justify-content:space-between; align-items:center; padding:8px; margin-bottom:8px; background:#fafafa; border-radius:6px; border-left:3px solid #409EFF; }
.${NS}-stat-row span { font-size:13px; color:#444; }
.${NS}-stat-row .value { font-weight:700; background:#fff; padding:4px 8px; border-radius:4px; min-width:70px; text-align:center; }
.${NS}-btn { width:100%; padding:9px; border-radius:6px; border:none; color:#fff; font-weight:700; cursor:pointer; margin-bottom:8px; }
.${NS}-btn.start { background:#67C23A; } .${NS}-btn.stop{ background:#F56C6C; } .${NS}-btn.clear{ background:#909399; }
.${NS}-meta { font-size:12px; color:#666; border-top:1px dashed #eee; padding-top:8px; margin-top:6px; }
`);
let apiCache = {};
let dataHistory = GM_getValue(`${NS}_dataHistory`, []) || [];
let autoInterval = null;
let isRunning = false;
let headersCaptured = Boolean(GM_getValue(CAPTURE_KEY, null));
function getUTC03Range() {
const now = new Date();
const y = now.getUTCFullYear(), m = now.getUTCMonth(), d = now.getUTCDate();
let start = new Date(Date.UTC(y,m,d,3,0,0,0));
if (now.getTime() < start.getTime()) start = new Date(start.getTime() - 24*3600*1000);
const end = new Date(start.getTime() + 24*3600*1000 - 1000);
return { startISO: start.toISOString(), endISO: end.toISOString(), text: `${start.toISOString()} → ${end.toISOString()}` };
}
function formatAmount(raw) {
if (raw === null || raw === undefined || isNaN(Number(raw))) return '--';
return Math.floor(Number(raw) / 100);
}
function addPanel() {
if (document.getElementById(`${NS}_panel`)) return;
const panel = document.createElement('div');
panel.id = `${NS}_panel`;
panel.className = `${NS}-panel`;
panel.innerHTML = `
<button class="${NS}-toggle" id="${NS}_toggle">×</button>
<div class="${NS}-header">📊 CP 出款&充值统计</div>
<!-- 充值统计 -->
<div class="${NS}-stat-row"><span>充值总额</span><span class="value" id="${NS}_payTotal">--</span></div>
<div class="${NS}-stat-row"><span>充值人数</span><span class="value" id="${NS}_payUsers">--</span></div>
<!-- 出款统计 -->
<div class="${NS}-stat-row"><span>今日提现总金额</span><span class="value" id="${NS}_totalAmount">--</span></div>
<div class="${NS}-stat-row"><span>今日CP出款比列</span><span class="value" id="${NS}_cpRatio">--</span></div>
<div class="${NS}-stat-row"><span>今日充提差</span><span class="value" id="${NS}_chargeWithdrawDiff">--</span></div>
<div style="margin-top:8px;">
<button id="${NS}_start" class="${NS}-btn start">开始统计</button>
<button id="${NS}_stop" class="${NS}-btn stop" style="display:none">停止统计</button>
<button id="${NS}_clear" class="${NS}-btn clear">清理缓存</button>
</div>
<div class="${NS}-meta">
<div>状态: <span id="${NS}_status">等待捕获头</span></div>
<div>最后更新: <span id="${NS}_last">--</span> 下次更新: <span id="${NS}_next">--</span></div>
</div>
`;
document.body.appendChild(panel);
document.getElementById(`${NS}_toggle`).addEventListener('click', togglePanel);
document.getElementById(`${NS}_start`).addEventListener('click', start);
document.getElementById(`${NS}_stop`).addEventListener('click', stop);
document.getElementById(`${NS}_clear`).addEventListener('click', clearAll);
updateHeaderStatus();
}
function togglePanel() {
const p = document.getElementById(`${NS}_panel`);
const isCollapsed = p.classList.toggle(`${NS}_collapsed`);
const btn = document.getElementById(`${NS}_toggle`);
btn.innerText = isCollapsed ? '≡' : '×';
}
function renderStats(res) {
const cpRatio = (res.cpUserCount && res.payUsers) ? ((res.cpUserCount / res.payUsers) * 100).toFixed(2) + '%' : '--';
const chargeWithdrawDiff = (res.totalAmount && res.cpAmount && res.payTotal)
? (((res.totalAmount - res.cpAmount) / res.payTotal) * 100).toFixed(2) + '%'
: '--';
document.getElementById(`${NS}_totalAmount`).textContent = res.totalAmount ?? '--'; // 今日提现总金额
document.getElementById(`${NS}_cpRatio`).textContent = cpRatio; // 今日CP出款比例
document.getElementById(`${NS}_chargeWithdrawDiff`).textContent = chargeWithdrawDiff; // 今日充提差
document.getElementById(`${NS}_payTotal`).textContent = res.payTotal ?? '--';
document.getElementById(`${NS}_payUsers`).textContent = res.payUsers ?? '--';
document.getElementById(`${NS}_last`).textContent = new Date().toLocaleTimeString();
}
function updateHeaderStatus() {
const s = document.getElementById(`${NS}_status`);
const st = GM_getValue(CAPTURE_KEY, null);
headersCaptured = !!st;
if (s) {
s.textContent = headersCaptured ? '已捕获头 ✓' : '等待捕获头...';
s.style.color = headersCaptured ? '#67C23A' : '#E6A23C';
}
}
function updateNextText() {
const el = document.getElementById(`${NS}_next`);
if (!el) return;
el.textContent = isRunning ? new Date(Date.now() + CONFIG.refreshInterval).toLocaleTimeString() : '--';
}
/* ---------- 自动捕获 headers ---------- */
function setupAutoCapture() {
if (window.__cp_capture_installed) return;
window.__cp_capture_installed = true;
const nativeFetch = window.fetch;
window.fetch = function(input, init) {
try { const req = new Request(input, init); attemptCapture(Object.fromEntries(req.headers.entries())); } catch(e){}
return nativeFetch.apply(this, arguments);
};
const origOpen = XMLHttpRequest.prototype.open;
const origSet = XMLHttpRequest.prototype.setRequestHeader;
const origSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url) { this.__cp_headers = {}; return origOpen.apply(this, arguments); };
XMLHttpRequest.prototype.setRequestHeader = function(k,v){ this.__cp_headers[k.toLowerCase()]=v; return origSet.apply(this, arguments); };
XMLHttpRequest.prototype.send = function(){ attemptCapture(this.__cp_headers); return origSend.apply(this, arguments); };
}
function attemptCapture(headers) {
if (!headers || GM_getValue(CAPTURE_KEY, null)) return;
const useful = {};
if (headers['authorization']) useful['authorization']=headers['authorization'];
if (headers['fingerprint-id']) useful['fingerprint-id']=headers['fingerprint-id'];
if (headers['x-trace-id']) useful['x-trace-id']=headers['x-trace-id'];
if (headers['cookie']) useful['cookie']=headers['cookie'];
if (Object.keys(useful).length>0) GM_setValue(CAPTURE_KEY,{capturedAt:new Date().toISOString(),useful,all:headers});
updateHeaderStatus();
}
/**
* 尝试通过在页面上下文触发一次 fetch 来促使页面自己刷新/注入 token,
* 然后等待 setupAutoCapture 捕获新头并写入 GM 存储。
*
* timeoutMs: 等待捕获新头的最大时间(毫秒)
* triggerPath: 用于触发的接口路径(默认使用 payPath)
*
* 返回 Promise<boolean>:true 表示捕获到新头,false 表示失败
*/
function tryAutoRecoverAuth(timeoutMs = 4000, triggerPath = CONFIG.payPath) {
return new Promise((resolve) => {
try {
// 1) 清除旧的捕获,确保后续捕获会写入新的值
try { GM_deleteValue(CAPTURE_KEY); } catch (e) { /* 忽略 */ }
// 2) 确保拦截器已安装(如果没有会安装)
try { setupAutoCapture(); } catch (e) { /* 忽略 */ }
// 3) 构造会触发页面自己组装 token 的 URL(使用 payPath)
const range = getUTC03Range();
const payload = {
json: {
queryType: 'statistics',
page: 1,
pageSize: 1,
status: 'PAID',
timeType: 'createTime',
regionId: 1,
tenantId: CONFIG.tenantId,
startTime: range.startISO,
endTime: range.endISO,
tableType: 'all'
}
};
const triggerUrl = `${CONFIG.apiBaseUrl}${triggerPath}?input=${encodeURIComponent(JSON.stringify(payload))}`;
// 4) 在页面上下文注入并执行 fetch(credentials: 'include' 确保带 cookie)
// 我们不关心响应体,只要页面发出了请求,setupAutoCapture 就能捕获头
const scriptId = '__cp_try_recover_fetch';
try {
// 移除可能残留的旧脚本
const old = document.getElementById(scriptId);
if (old) old.remove();
const script = document.createElement('script');
script.id = scriptId;
script.type = 'text/javascript';
script.textContent = `
(function(){
try {
fetch(${JSON.stringify(triggerUrl)}, { method: 'GET', credentials: 'include' })
.then(()=>{/* done */}).catch(()=>{/* ignore */});
} catch(e) {}
})();
`;
// 插入并稍后移除
document.documentElement.appendChild(script);
} catch (e) {
// 如果注入失败(极少见),后面仍会等待但很可能超时
console.warn('注入触发脚本失败', e);
}
// 5) 轮询检查 GM 存储中是否写入了新的捕获头
const start = Date.now();
const checkInterval = 250;
const checker = setInterval(() => {
try {
const stored = GM_getValue(CAPTURE_KEY, null);
if (stored && stored.useful && (stored.useful.authorization || stored.useful['fingerprint-id'] || stored.useful['x-trace-id'] || stored.useful.cookie)) {
clearInterval(checker);
// 清理注入脚本
try { document.getElementById(scriptId)?.remove(); } catch (e) {}
resolve(true);
return;
}
} catch (e) {
// 读取 GM 可能抛错,忽略继续等待
}
if (Date.now() - start > timeoutMs) {
clearInterval(checker);
try { document.getElementById(scriptId)?.remove(); } catch (e) {}
resolve(false);
}
}, checkInterval);
} catch (err) {
console.warn('tryAutoRecoverAuth 异常', err);
resolve(false);
}
});
}
/**
* - 若检测到 401/未授权 或 接口返回结构表明 token 失效 => 尝试自动恢复 auth(tryAutoRecoverAuth)
* - 恢复成功后重试一次请求(只重试一次以避免无限循环)
*/
function gmFetchJson(url, timeout = 20000, maxRetries = 1) {
return new Promise((resolve, reject) => {
let attempt = 0;
let retriedAfterRecover = false;
const doRequest = () => {
attempt++;
const stored = GM_getValue(CAPTURE_KEY, null);
const headers = {'accept':'*/*','content-type':'application/json'};
if (stored && stored.useful) Object.assign(headers, stored.useful);
// 如果没有 cookie 且页面有 cookie,则注入
try { if (!headers.cookie && document.cookie) headers.cookie = document.cookie; } catch (e) {}
try {
GM_xmlhttpRequest({
method: 'GET',
url: url,
headers: headers,
responseType: 'text',
timeout: timeout,
onload: function(res) {
console.log(headers)
// 如果 401, 先尝试自动恢复 token
if (res.status === 401 || res.status === 403 || (res.status >= 200 && res.status < 300 && (res.responseText||'').toLowerCase().includes('unauthorized'))) {
console.warn('请求返回未授权或401/403', res.status);
// 在遇到 401/403 的时候
if (!retriedAfterRecover) {
retriedAfterRecover = true;
const recovered = tryAutoRecoverAuth(4000, CONFIG.payPath); // 强制尝试恢复,不再被已有 token 的存在短路
if (recovered) {
setTimeout(doRequest, 300);
return;
} else {
reject(new Error('未授权(401/403),且自动恢复 token 失败'));
return;
}
} else {
const err = new Error('未授权(401/403),重试后仍失败');
reject(err);
return;
}
}
// 非 2xx 状态按错误处理(可重试)
if (!(res.status >= 200 && res.status < 300)) {
const err = new Error(`HTTP ${res.status}`);
if (attempt <= maxRetries) {
setTimeout(doRequest, 200 * attempt);
} else {
reject(err);
}
return;
}
// 2xx 状态,尝试解析 JSON
try {
const parsed = JSON.parse(res.responseText);
resolve(parsed);
} catch (e) {
// JSON 解析失败,视作错误,可重试
const err = new Error('响应 JSON 解析失败: ' + (e.message || e));
if (attempt <= maxRetries) {
setTimeout(doRequest, 200 * attempt);
} else {
reject(err);
}
}
},
onerror: function(err) {
const e = new Error('GM_xmlhttpRequest 网络错误');
if (attempt <= maxRetries) {
setTimeout(doRequest, 200 * attempt);
} else {
reject(e);
}
},
ontimeout: function() {
const e = new Error('GM_xmlhttpRequest 超时');
if (attempt <= maxRetries) {
setTimeout(doRequest, 200 * attempt);
} else {
reject(e);
}
}
});
} catch (e) {
reject(new Error('GM_xmlhttpRequest 调用异常: ' + (e.message || e)));
}
};
doRequest();
});
}
/* ---------- 构造URL ---------- */
// 今日出款提现数据
function buildUrlForTotalWithdraw(page=1) {
const range=getUTC03Range();
const payload={json:{page,pageSize:CONFIG.pageSize,status:"success",queryTimeType:"completeTime",regionId:1,tenantId:CONFIG.tenantId,startTime:range.startISO,endTime:range.endISO,startTimeUTC:range.startISO,endTimeUTC:range.endISO}};
return `${CONFIG.apiBaseUrl}${CONFIG.withdrawalPath}?input=${encodeURIComponent(JSON.stringify(payload))}`;
}
// 今日coinpay出款提现数据
function buildUrlForWithdraw(page=1) {
const range=getUTC03Range();
const payload={json:{page,pageSize:CONFIG.pageSize,status:"success",queryTimeType:"completeTime",regionId:1,tenantId:CONFIG.tenantId,withdrawalChannels:CONFIG.withdrawalChannels,startTime:range.startISO,endTime:range.endISO,startTimeUTC:range.startISO,endTimeUTC:range.endISO}};
return `${CONFIG.apiBaseUrl}${CONFIG.withdrawalPath}?input=${encodeURIComponent(JSON.stringify(payload))}`;
}
// 今日充值数据
function buildUrlForPay() {
const range = getUTC03Range();
const payload={json:{queryType:'statistics',page:1,pageSize:50,status:'PAID',timeType:'createTime',regionId:1,tenantId:CONFIG.tenantId,startTime:range.startISO,endTime:range.endISO,tableType:'all'}};
return `${CONFIG.apiBaseUrl}${CONFIG.payPath}?input=${encodeURIComponent(JSON.stringify(payload))}`;
}
/* ====== 更健壮的 fetchPayData:防止 result/data 未定义 并提供备用解析 ====== */
async function fetchPayData() {
try {
const url = buildUrlForPay();
const json = await gmFetchJson(url);
// 严格检查路径 result -> data -> json -> totalInfo
const totalInfo = json?.result?.data?.json?.totalInfo
// 如果上面路径没有命中,尝试一些可能的变体(兼容性处理)
|| json?.data?.json?.totalInfo
|| json?.result?.data?.totalInfo
|| json?.result?.data?.json?.total // 有些版本把 total 直接放这里
|| null;
if (!totalInfo) {
// 打印完整响应头 方便排查(只打印部分)
console.warn('fetchPayData: 未在响应中找到 totalInfo,响应片段:',
typeof json === 'object' ? JSON.stringify(json).slice(0,1000) : String(json));
// 返回默认占位,不抛出异常,保证脚本继续运行
return { payTotal: '--', payCount: '--', payUsers: '--' };
}
// 兼容 totalInfo 字段名与类型(部分接口用字符串数字)
// 优先使用 totalPayAmount -> totalPayAmount 有可能是字符串 "1022200"
const totalPayRaw = Number(totalInfo.totalPayAmount ?? 0);
const totalCount = Number(totalInfo.total ?? 0);
const totalUsers = Number(totalInfo.totalUser ?? 0);
// 若解析出 NaN,保底置 0
const safeTotalPay = isNaN(totalPayRaw) ? 0 : totalPayRaw;
const safeCount = isNaN(totalCount) ? 0 : totalCount;
const safeUsers = isNaN(totalUsers) ? 0 : totalUsers;
return {
payTotal: formatAmount(safeTotalPay), // 以分为单位,formatAmount 会除以100并取整
payCount: safeCount, // 充值单数(原返回 total)
payUsers: safeUsers // 充值人数
};
} catch (e) {
console.warn('拉取充值失败(已捕获异常):', e);
// 不抛出,返回占位,保证自动刷新不会中断
return { payTotal: '--', payCount: '--', payUsers: '--' };
}
}
/* ---------- 聚合今日coinpay出款统计 ---------- */
async function fetchWithdrawData() {
try {
const range = getUTC03Range();
// ========== 1️⃣ 分页查询总出款 ==========
async function fetchTotalWithdraw() {
let page = 1;
let totalAmountRaw = 0;
const pageSize = CONFIG.pageSize;
while (true) {
const payload = {
json: {
page,
pageSize,
status: "success",
queryTimeType: "completeTime",
regionId: 1,
tenantId: CONFIG.tenantId,
startTime: range.startISO,
endTime: range.endISO
}
};
const url = `${CONFIG.apiBaseUrl}${CONFIG.withdrawalPath}?input=${encodeURIComponent(JSON.stringify(payload))}`;
const json = await gmFetchJson(url);
const items = json?.result?.data?.json?.queryData ?? [];
// 累计金额
for (const it of items) {
totalAmountRaw += Number(it.actualWithdrawals ?? it.amount ?? 0);
}
if (items.length < pageSize) break; // 没有下一页
page++;
}
return totalAmountRaw;
}
// ========== 2️⃣ 分页查询 CoinPay 出款 ==========
async function fetchCoinPayWithdraw() {
let page = 1;
let cpAmountRaw = 0;
const cpUsers = new Set();
const pageSize = CONFIG.pageSize;
while (true) {
const payload = {
json: {
page,
pageSize,
status: "success",
queryTimeType: "completeTime",
regionId: 1,
tenantId: CONFIG.tenantId,
withdrawalChannels: CONFIG.withdrawalChannels, // ★ 关键:区分 CoinPay 出款
startTime: range.startISO,
endTime: range.endISO
}
};
const url = `${CONFIG.apiBaseUrl}${CONFIG.withdrawalPath}?input=${encodeURIComponent(JSON.stringify(payload))}`;
const json = await gmFetchJson(url);
const items = json?.result?.data?.json?.queryData ?? [];
for (const it of items) {
cpAmountRaw += Number(it.actualWithdrawals ?? it.amount ?? 0);
if (it.userId) cpUsers.add(String(it.userId));
}
if (items.length < pageSize) break;
page++;
}
return { cpAmountRaw, cpUsers };
}
// ========== 3️⃣ 执行两个分页任务 ==========
const totalAmountRaw = await fetchTotalWithdraw();
const { cpAmountRaw, cpUsers } = await fetchCoinPayWithdraw();
// ========== 4️⃣ 返回统一结构 ==========
return {
totalAmount: formatAmount(totalAmountRaw), // 今日总出款
cpAmount: formatAmount(cpAmountRaw), // 今日 CP 出款
cpUserCount: cpUsers.size // 今日 CP 出款人数
};
} catch (e) {
console.warn('拉取出款失败', e);
return { totalAmount: '--', cpAmount: '--', cpUserCount: '--' };
}
}
async function collectAndCompute() {
const [withdraw,pay] = await Promise.all([fetchWithdrawData(), fetchPayData()]);
const result = {...withdraw,...pay};
dataHistory.push({ts:Date.now(),range:getUTC03Range().text,data:result});
GM_setValue(`${NS}_dataHistory`, dataHistory.slice(-50));
return {result, range:getUTC03Range().text};
}
async function autoRefresh() {
if(!headersCaptured) return;
updateNextText();
try{
const {result,range} = await collectAndCompute();
renderStats(result,range);
}catch(e){ console.error('自动刷新失败',e); }
}
function start() {
if(!headersCaptured){ alert('请先触发任意 API 请求以捕获头信息'); return; }
if(isRunning) return;
isRunning=true;
document.getElementById(`${NS}_start`).style.display='none';
document.getElementById(`${NS}_stop`).style.display='block';
autoRefresh();
autoInterval=setInterval(autoRefresh,CONFIG.refreshInterval);
}
function stop() {
if(!isRunning) return;
isRunning=false;
clearInterval(autoInterval);
document.getElementById(`${NS}_start`).style.display='block';
document.getElementById(`${NS}_stop`).style.display='none';
updateNextText();
}
function clearAll() {
apiCache={};
dataHistory=[];
GM_deleteValue(`${NS}_dataHistory`);
renderStats({ totalAmount:'--', totalCount:'--', userSum:'--', avgAmount:'--', cpRatio:'--', payTotal:'--', payCount:'--', payUsers:'--' }, '--');
}
addPanel();
setupAutoCapture();
})();