mirror of
https://wget.la/https://github.com/432539/gpt
synced 2026-08-17 03:26:59 +08:00
只开源「GPT 账号注册机 / 管理」(account-manager):账号列表/导入导出、检测/批量检测、 拿 RT、协议/浏览器注册等注册机能力。批量直开 / 协议支付 / 提取链接 / 直卡直开 等其它功能 不含实现代码,仅通过 iframe 内嵌外链访问。真实机密均由 .gitignore 忽略,仅提供脱敏示例配置。
320 lines
13 KiB
JavaScript
320 lines
13 KiB
JavaScript
// PLUS 邮箱收信 —— 微软 Outlook/Hotmail OAuth(refresh_token + client_id)
|
||
// 导入格式:email----password----clientId----refreshToken
|
||
// 收信流程:refresh_token 换 access_token → 优先 Microsoft Graph 读收件箱,失败回退 IMAP(XOAUTH2)
|
||
import tls from 'node:tls';
|
||
|
||
const TOKEN_URL = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token';
|
||
// 这些批量 Outlook 账号的 refresh_token 是按「资源级同意」(.default) 颁发的,
|
||
// 直接请求细粒度 scope(如 https://graph.microsoft.com/Mail.Read)会被拒:
|
||
// AADSTS70000 invalid_grant: one or more scopes requested are unauthorized or expired
|
||
// 因此每种传输都按候选 scope 依次尝试,取第一个成功者(.default 优先,细粒度兜底)。
|
||
const GRAPH_SCOPES = [
|
||
'https://graph.microsoft.com/.default',
|
||
'https://graph.microsoft.com/Mail.Read offline_access',
|
||
];
|
||
const IMAP_SCOPES = [
|
||
'https://outlook.office.com/IMAP.AccessAsUser.All offline_access',
|
||
'https://outlook.office.com/.default',
|
||
];
|
||
const IMAP_HOST = 'outlook.office365.com';
|
||
const IMAP_PORT = 993;
|
||
|
||
const tokenCache = new Map(); // key: clientId|refreshToken|scope -> { token, ts }
|
||
const TOKEN_TTL = 45 * 60 * 1000;
|
||
|
||
// ---------- 解析导入格式 ----------
|
||
// 每行:email----password----clientId----refreshToken(也兼容用 | 或制表符分隔的多字段)
|
||
export function parsePlus(text) {
|
||
const out = [];
|
||
const seen = new Set();
|
||
for (const raw of String(text || '').split(/\r?\n/)) {
|
||
const line = raw.trim();
|
||
if (!line) continue;
|
||
let parts;
|
||
if (line.includes('----')) parts = line.split('----');
|
||
else if (line.includes('\t')) parts = line.split('\t');
|
||
else if (line.includes('|')) parts = line.split('|');
|
||
else parts = line.split(/\s+/);
|
||
parts = parts.map((s) => s.trim()).filter(Boolean);
|
||
if (parts.length < 4) continue;
|
||
const [email, password, clientId, refreshToken] = parts;
|
||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) continue;
|
||
const key = email.toLowerCase();
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
out.push({ email, password, clientId, refreshToken });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ---------- 验证码提取(与 buyer 收信口径一致:4-8 位数字优先)----------
|
||
export function extractCode(text) {
|
||
if (!text) return '';
|
||
const s = String(text);
|
||
const labeled = s.match(/(?:code|验证码|verification|otp|passcode)[^\d]{0,12}(\d{4,8})/i);
|
||
if (labeled) return labeled[1];
|
||
const m = s.match(/\b(\d{4,8})\b/);
|
||
return m ? m[1] : '';
|
||
}
|
||
|
||
// ---------- 换取 access_token(单个 scope)----------
|
||
async function exchangeToken(clientId, refreshToken, scope) {
|
||
const ck = `${clientId}|${refreshToken}|${scope}`;
|
||
const cached = tokenCache.get(ck);
|
||
if (cached && Date.now() - cached.ts < TOKEN_TTL) return cached.token;
|
||
const params = new URLSearchParams();
|
||
params.set('client_id', clientId);
|
||
params.set('grant_type', 'refresh_token');
|
||
params.set('refresh_token', refreshToken);
|
||
params.set('scope', scope);
|
||
const res = await fetch(TOKEN_URL, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||
body: params.toString(),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok || !data.access_token) {
|
||
const e = new Error(`换取令牌失败:${data.error || res.status} ${data.error_description ? '· ' + String(data.error_description).slice(0, 120) : ''}`);
|
||
e.status = res.status;
|
||
e.oauthError = data.error || '';
|
||
throw e;
|
||
}
|
||
tokenCache.set(ck, { token: data.access_token, ts: Date.now() });
|
||
return data.access_token;
|
||
}
|
||
|
||
// ---------- 换取 access_token(按候选 scope 回退)----------
|
||
// scopes 为候选 scope 数组,依次尝试,返回第一个成功者的 token。
|
||
// 遇到 invalid_grant/scope 类错误继续尝试下一个 scope;全部失败则抛出最后错误。
|
||
// 成功时打印命中的 scope,便于观察是哪种同意口径生效。
|
||
async function getAccessToken(clientId, refreshToken, scopes) {
|
||
const candidates = Array.isArray(scopes) ? scopes : [scopes];
|
||
let lastErr = null;
|
||
for (const scope of candidates) {
|
||
try {
|
||
const token = await exchangeToken(clientId, refreshToken, scope);
|
||
if (candidates.length > 1) {
|
||
console.log(`[plus-mailbox] 令牌换取成功 scope=${scope}`);
|
||
}
|
||
return token;
|
||
} catch (e) {
|
||
lastErr = e;
|
||
// scope 未授权/过期 → 尝试下一个候选;其它错误(RT 失效等)同样继续,
|
||
// 但若全部候选都失败则把最后的错误抛出(通常即真正原因)。
|
||
continue;
|
||
}
|
||
}
|
||
throw lastErr || new Error('换取令牌失败:无可用 scope');
|
||
}
|
||
|
||
// ---------- MIME 解码:编码字(RFC2047)/ quoted-printable / base64 ----------
|
||
function decodeQP(str, isWord = false) {
|
||
let s = String(str || '');
|
||
if (isWord) s = s.replace(/_/g, ' ');
|
||
return s
|
||
.replace(/=\r?\n/g, '')
|
||
.replace(/=([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
||
}
|
||
function utf8(bytesStr) {
|
||
// bytesStr 为 latin1 字节串,按 UTF-8 还原
|
||
try { return Buffer.from(bytesStr, 'latin1').toString('utf8'); } catch { return bytesStr; }
|
||
}
|
||
function decodeEncodedWords(str) {
|
||
return String(str || '').replace(/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g, (_, charset, enc, data) => {
|
||
try {
|
||
let bytes;
|
||
if (enc.toUpperCase() === 'B') bytes = Buffer.from(data, 'base64').toString('latin1');
|
||
else bytes = decodeQP(data, true);
|
||
return /utf-?8/i.test(charset) ? utf8(bytes) : bytes;
|
||
} catch { return data; }
|
||
}).replace(/\?=\s*=\?/g, ''); // 相邻编码字之间的折行分隔
|
||
}
|
||
|
||
// ---------- HTML → 纯文本 ----------
|
||
function htmlToText(html) {
|
||
return String(html || '')
|
||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||
.replace(/<[^>]+>/g, ' ')
|
||
.replace(/ /g, ' ')
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/&#(\d+);/g, (_, d) => String.fromCharCode(Number(d)))
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
// ---------- Graph 读收件箱 ----------
|
||
async function fetchViaGraph(clientId, refreshToken, limit) {
|
||
const token = await getAccessToken(clientId, refreshToken, GRAPH_SCOPES);
|
||
const url = `https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages?$top=${limit}` +
|
||
`&$select=subject,from,receivedDateTime,bodyPreview,body&$orderby=receivedDateTime desc`;
|
||
const res = await fetch(url, { headers: { authorization: `Bearer ${token}` } });
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
const e = new Error(`Graph 读信失败:${(data.error && data.error.message) || res.status}`);
|
||
e.status = res.status;
|
||
throw e;
|
||
}
|
||
return (data.value || []).map((m) => {
|
||
const bodyText = m.body && m.body.contentType === 'html' ? htmlToText(m.body.content) : (m.body && m.body.content) || m.bodyPreview || '';
|
||
return {
|
||
from_addr: (m.from && m.from.emailAddress && m.from.emailAddress.address) || '',
|
||
subject: m.subject || '',
|
||
received_at: m.receivedDateTime || '',
|
||
body: bodyText.slice(0, 4000),
|
||
snippet: m.bodyPreview || '',
|
||
extracted_code: extractCode(`${m.subject || ''} ${m.bodyPreview || ''} ${bodyText}`),
|
||
};
|
||
});
|
||
}
|
||
|
||
// ---------- IMAP(XOAUTH2) 读收件箱(Graph 失败时回退)----------
|
||
function imapRead(email, token, limit) {
|
||
return new Promise((resolve, reject) => {
|
||
const sock = tls.connect(IMAP_PORT, IMAP_HOST, { servername: IMAP_HOST }, () => {});
|
||
let buf = '';
|
||
let stage = 'greeting';
|
||
let tag = 0;
|
||
let exists = 0;
|
||
let raw = '';
|
||
let settled = false;
|
||
const timer = setTimeout(() => finish(new Error('IMAP 超时')), 20000);
|
||
function finish(err, val) {
|
||
if (settled) return;
|
||
settled = true;
|
||
clearTimeout(timer);
|
||
try { sock.end(); sock.destroy(); } catch {}
|
||
err ? reject(err) : resolve(val);
|
||
}
|
||
const send = (cmd) => { tag++; sock.write(`a${tag} ${cmd}\r\n`); return `a${tag}`; };
|
||
sock.setEncoding('utf8');
|
||
sock.on('error', (e) => finish(new Error('IMAP ' + (e.code || e.message))));
|
||
sock.on('data', (chunk) => {
|
||
buf += chunk;
|
||
if (stage === 'greeting') {
|
||
if (!/\r\n/.test(buf)) return;
|
||
buf = '';
|
||
stage = 'auth';
|
||
const b64 = Buffer.from(`user=${email}\x01auth=Bearer ${token}\x01\x01`).toString('base64');
|
||
sock.write(`a1 AUTHENTICATE XOAUTH2 ${b64}\r\n`);
|
||
tag = 1;
|
||
return;
|
||
}
|
||
if (stage === 'auth') {
|
||
if (/^\+/m.test(buf)) { sock.write('\r\n'); } // 服务器要求继续 → 发空行以取回错误
|
||
if (/^a1 OK/mi.test(buf)) { buf = ''; stage = 'select'; send('SELECT INBOX'); return; }
|
||
if (/^a1 (NO|BAD)/mi.test(buf)) return finish(new Error('IMAP 认证失败(XOAUTH2)'));
|
||
return;
|
||
}
|
||
if (stage === 'select') {
|
||
const m = buf.match(/\*\s+(\d+)\s+EXISTS/i);
|
||
if (m) exists = Number(m[1]);
|
||
if (/^a2 OK/mi.test(buf)) {
|
||
buf = '';
|
||
if (!exists) return finish(null, []);
|
||
const from = Math.max(1, exists - limit + 1);
|
||
stage = 'fetch';
|
||
send(`FETCH ${from}:${exists} (BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)] BODY.PEEK[TEXT])`);
|
||
return;
|
||
}
|
||
if (/^a2 (NO|BAD)/mi.test(buf)) return finish(new Error('IMAP SELECT 失败'));
|
||
return;
|
||
}
|
||
if (stage === 'fetch') {
|
||
raw += chunk;
|
||
if (/^a3 OK/mi.test(raw)) return finish(null, parseImapFetch(raw));
|
||
if (/^a3 (NO|BAD)/mi.test(raw)) return finish(new Error('IMAP FETCH 失败'));
|
||
}
|
||
});
|
||
// buf 只在非 fetch 阶段用;fetch 阶段用 raw
|
||
});
|
||
}
|
||
|
||
// 从 BODY[TEXT] 原文中挑出可读正文段:multipart 优先 text/plain,其次 text/html
|
||
function pickBodyPart(rawBody) {
|
||
let segment = rawBody;
|
||
const boundaryM = rawBody.match(/boundary="?([^"\r\n;]+)"?/i);
|
||
if (boundaryM) {
|
||
const bnd = boundaryM[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
const parts = rawBody.split(new RegExp('--' + bnd));
|
||
let plain = null, html = null;
|
||
for (const p of parts) {
|
||
if (!plain && /Content-Type:\s*text\/plain/i.test(p)) plain = p;
|
||
else if (!html && /Content-Type:\s*text\/html/i.test(p)) html = p;
|
||
}
|
||
segment = plain || html || rawBody;
|
||
}
|
||
const cte = (segment.match(/Content-Transfer-Encoding:\s*([\w-]+)/i) || [])[1] || '';
|
||
const charset = (segment.match(/charset=?["']?\s*([\w-]+)/i) || [])[1] || '';
|
||
const headEnd = segment.search(/\r?\n\r?\n/);
|
||
const content = headEnd >= 0 ? segment.slice(headEnd).replace(/^\s+/, '') : segment;
|
||
return { content, cte, isUtf8: /utf-?8/i.test(charset) };
|
||
}
|
||
|
||
// 极简解析:按邮件块切分,抓取 From/Subject/Date 和正文文本,并做 MIME 解码
|
||
function parseImapFetch(raw) {
|
||
const out = [];
|
||
const blocks = raw.split(/\*\s+\d+\s+FETCH/i).slice(1);
|
||
for (const b of blocks) {
|
||
const from = decodeEncodedWords((b.match(/From:\s*(.*)/i) || [])[1] || '');
|
||
const subject = decodeEncodedWords((b.match(/Subject:\s*(.*)/i) || [])[1] || '').trim();
|
||
const date = ((b.match(/Date:\s*(.*)/i) || [])[1] || '').trim();
|
||
const textIdx = b.search(/BODY\[TEXT\]/i);
|
||
let body = '';
|
||
if (textIdx >= 0) {
|
||
const after = b.slice(textIdx);
|
||
const lit = after.match(/\{(\d+)\}\r?\n/);
|
||
if (lit) {
|
||
const start = after.indexOf(lit[0]) + lit[0].length;
|
||
body = after.slice(start, start + Number(lit[1]));
|
||
}
|
||
}
|
||
// 挑选正文段(multipart 优先 text/plain),按其自身编码解码
|
||
const part = pickBodyPart(body);
|
||
let content = part.content;
|
||
if (/base64/i.test(part.cte)) {
|
||
try { content = Buffer.from(content.replace(/\s+/g, ''), 'base64').toString(part.isUtf8 ? 'utf8' : 'latin1'); } catch {}
|
||
} else if (/quoted-printable/i.test(part.cte)) {
|
||
content = decodeQP(content);
|
||
if (part.isUtf8) content = utf8(content);
|
||
} else {
|
||
content = decodeQP(content); // 兜底:常见 =XX 也一并还原
|
||
if (part.isUtf8) content = utf8(content);
|
||
}
|
||
body = htmlToText(content).slice(0, 4000);
|
||
out.push({
|
||
from_addr: from.trim().replace(/^.*<|>.*$/g, '').trim() || from.trim(),
|
||
subject,
|
||
received_at: date,
|
||
body,
|
||
snippet: body.slice(0, 160),
|
||
extracted_code: extractCode(`${subject} ${body}`),
|
||
});
|
||
}
|
||
return out.reverse();
|
||
}
|
||
|
||
// ---------- 对外统一入口 ----------
|
||
export async function fetchPlusInbox({ email, clientId, refreshToken }, limit = 15) {
|
||
if (!clientId || !refreshToken) {
|
||
const e = new Error('缺少 clientId / refreshToken');
|
||
e.status = 400;
|
||
throw e;
|
||
}
|
||
try {
|
||
const messages = await fetchViaGraph(clientId, refreshToken, limit);
|
||
return { address: email, via: 'graph', messages };
|
||
} catch (graphErr) {
|
||
// Graph 不通(scope 未授权等)→ 回退 IMAP
|
||
try {
|
||
const token = await getAccessToken(clientId, refreshToken, IMAP_SCOPES);
|
||
const messages = await imapRead(email, token, limit);
|
||
return { address: email, via: 'imap', messages };
|
||
} catch (imapErr) {
|
||
const e = new Error(`收信失败(Graph: ${graphErr.message};IMAP: ${imapErr.message})`);
|
||
e.status = graphErr.status || 502;
|
||
throw e;
|
||
}
|
||
}
|
||
}
|