Initial commit: GPT 账号注册机 (open source)

只开源「GPT 账号注册机 / 管理」(account-manager):账号列表/导入导出、检测/批量检测、
拿 RT、协议/浏览器注册等注册机能力。批量直开 / 协议支付 / 提取链接 / 直卡直开 等其它功能
不含实现代码,仅通过 iframe 内嵌外链访问。真实机密均由 .gitignore 忽略,仅提供脱敏示例配置。
This commit is contained in:
432539
2026-08-05 14:54:32 +08:00
commit fef45134d5
29 changed files with 9829 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+118
View File
@@ -0,0 +1,118 @@
// 反检测:为每个注册账号合成一套「内部自洽」的浏览器/设备指纹 + 随机姓名 + 成年生日。
// 目的:以前所有账号都写死同一 UAChrome/145 Windows)、同一屏幕/语言、姓名只在 6×6 里选、
// 年龄挤在 1995~2002,风控极易把这些注册归成同一批机器人。这里让「每账号一套、全程一致、
// 账号间不同」——UA / sec-ch-ua / platform / screen / navigator 全部彼此对齐(否则 sec-ch-ua
// 与 UA 不符本身就是强特征)。
import crypto from 'node:crypto';
function pick(arr) { return arr[crypto.randomInt(0, arr.length)]; }
// 每个 profile 固定「Chrome 大版本 ↔ 真实的 sec-ch-ua 品牌串」配对:sec-ch-ua 的 GREASE 品牌
// "Not?A_Brand" 之类)与版本号是浏览器构建时生成的,随便拼会露馅,所以直接采样真实组合。
const CHROME_PROFILES = [
{ major: 140, secChUa: '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"' },
{ major: 141, secChUa: '"Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"' },
{ major: 142, secChUa: '"Chromium";v="142", "Google Chrome";v="142", "Not_A Brand";v="99"' },
{ major: 143, secChUa: '"Not;A=Brand";v="99", "Google Chrome";v="143", "Chromium";v="143"' },
{ major: 144, secChUa: '"Google Chrome";v="144", "Chromium";v="144", "Not.A/Brand";v="24"' },
{ major: 145, secChUa: '"Chromium";v="145", "Not)A;Brand";v="8", "Google Chrome";v="145"' },
{ major: 138, secChUa: '"Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"' },
{ major: 139, secChUa: '"Chromium";v="139", "Not_A Brand";v="24", "Google Chrome";v="139"' },
];
// OS 决定 UA 平台段、navigator.platform、sec-ch-ua-platform 三者必须一致。
const OS_PROFILES = [
{ uaToken: 'Windows NT 10.0; Win64; x64', platform: 'Win32', chPlatform: '"Windows"' },
{ uaToken: 'Macintosh; Intel Mac OS X 10_15_7', platform: 'MacIntel', chPlatform: '"macOS"' },
];
// 常见桌面分辨率(宽x高);taskbar/书签栏占用后 availHeight 略小于 height,贴近真机。
const SCREENS = [
{ width: 1920, height: 1080 },
{ width: 2560, height: 1440 },
{ width: 1536, height: 864 },
{ width: 1440, height: 900 },
{ width: 1366, height: 768 },
{ width: 1680, height: 1050 },
];
// 英文母语区:语言/locale 保持在英语圈内,避免 UA 是美式 Chrome 却报 zh-CN 这种矛盾。
const LOCALES = [
{ language: 'en-US', accept: 'en-US,en;q=0.9', tz: 'America/New_York' },
{ language: 'en-US', accept: 'en-US,en;q=0.9', tz: 'America/Chicago' },
{ language: 'en-US', accept: 'en-US,en;q=0.9', tz: 'America/Los_Angeles' },
{ language: 'en-GB', accept: 'en-GB,en;q=0.9', tz: 'Europe/London' },
{ language: 'en-CA', accept: 'en-CA,en;q=0.9', tz: 'America/Toronto' },
];
// 生成一套彼此自洽的指纹。同一账号整条注册流程复用同一个对象;不同账号各自独立随机。
export function generateFingerprint() {
const chrome = pick(CHROME_PROFILES);
const osp = pick(OS_PROFILES);
const screen = pick(SCREENS);
const loc = pick(LOCALES);
const hardwareConcurrency = pick([4, 8, 12, 16]);
const deviceMemory = pick([8, 16]); // Chrome 只暴露 8/16 这类粗粒度值
const userAgent =
`Mozilla/5.0 (${osp.uaToken}) AppleWebKit/537.36 (KHTML, like Gecko) ` +
`Chrome/${chrome.major}.0.0.0 Safari/537.36`;
return {
userAgent,
secChUa: chrome.secChUa,
secChUaMobile: '?0', // 桌面固定 ?0,与 UA 无移动标记一致
secChUaPlatform: osp.chPlatform,
platform: osp.platform,
language: loc.language,
acceptLanguage: loc.accept,
languages: `${loc.language},${loc.language.split('-')[0]}`,
timezone: loc.tz,
screen: {
width: screen.width,
height: screen.height,
// availHeight 减去任务栏/系统栏的典型高度,availWidth 通常等于 width。
availWidth: screen.width,
availHeight: screen.height - 40,
colorDepth: 24,
pixelDepth: 24,
},
screenStr: `${screen.width}x${screen.height}`,
hardwareConcurrency,
deviceMemory,
};
}
// 扩充姓名池:各数十个真实常见英文名,随机组合,避免总是那几对被风控聚类。
const FIRST_NAMES = [
'James', 'Michael', 'Robert', 'John', 'David', 'William', 'Richard', 'Joseph',
'Thomas', 'Christopher', 'Daniel', 'Matthew', 'Anthony', 'Andrew', 'Joshua',
'Ryan', 'Brandon', 'Justin', 'Benjamin', 'Samuel', 'Nathan', 'Aaron', 'Adam',
'Mary', 'Patricia', 'Jennifer', 'Linda', 'Elizabeth', 'Barbara', 'Susan',
'Jessica', 'Sarah', 'Karen', 'Emily', 'Emma', 'Olivia', 'Sophia', 'Ava',
'Isabella', 'Mia', 'Charlotte', 'Amelia', 'Hannah', 'Grace', 'Rachel',
'Laura', 'Megan', 'Lauren', 'Victoria',
];
const LAST_NAMES = [
'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis',
'Rodriguez', 'Martinez', 'Hernandez', 'Lopez', 'Gonzalez', 'Wilson', 'Anderson',
'Thomas', 'Taylor', 'Moore', 'Jackson', 'Martin', 'Lee', 'Perez', 'Thompson',
'White', 'Harris', 'Sanchez', 'Clark', 'Ramirez', 'Lewis', 'Robinson', 'Walker',
'Young', 'Allen', 'King', 'Wright', 'Scott', 'Torres', 'Hill', 'Green',
'Adams', 'Baker', 'Nelson', 'Carter', 'Mitchell', 'Roberts', 'Turner',
'Phillips', 'Campbell', 'Parker', 'Evans',
];
export function randomFullName() {
return `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)}`;
}
// 成年生日:约 18~48 岁均匀取样,月/日在安全范围(1~28)避免非法日期,格式 YYYY-MM-DD 与旧逻辑一致。
export function randomAdultBirthdate() {
const nowYear = new Date().getUTCFullYear();
const age = 18 + crypto.randomInt(0, 31); // 18..48
const year = nowYear - age;
const month = 1 + crypto.randomInt(0, 12);
const day = 1 + crypto.randomInt(0, 28);
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
File diff suppressed because it is too large Load Diff
+205
View File
@@ -0,0 +1,205 @@
// 接码 / SMS 验证码服务 —— hero-sms.com API 客户端(零依赖,使用全局 fetch)
// 协议:经典 sms-activate 风格,所有请求为 GET https://hero-sms.com/stubs/handler_api.php?api_key=..&action=..
// 响应为纯文本(冒号分隔),如 ACCESS_NUMBER:<activationId>:<phone>、STATUS_OK:<code>、ACCESS_BALANCE:<n>
//
// 与 smsbower.js / smscode.js 接口对齐(可作为调度器的替换供应商):
// acquireNumber(cfg, { log }) → { orderId, phone, order }
// pollOtp(cfg, id, { timeoutMs, intervalMs, log }) → { code, message, order }
// finishOrder(cfg, id) → setStatus status=6(完成)
// cancelOrder(cfg, id, { log }) → setStatus status=8(取消,2 分钟内不可取消时静默处理)
// getBalance(cfg) → 余额数字
//
// 已实测(2026-07):base=https://hero-sms.com/stubs/handler_api.php
// India country=22OpenAI service="dr"getServicesList 返回 {code:"dr",name:"OpenAI"})。
// 配置字段:apiKey、baseUrl(默认 hero-sms 地址)、country(默认 22=印度)、
// service(默认 "dr"=OpenAI)、maxPrice、minPrice
const DEFAULT_BASE = 'https://hero-sms.com/stubs/handler_api.php';
const DEFAULT_COUNTRY = 22; // 印度
const DEFAULT_SERVICE = 'dr'; // OpenAI
function normBase(baseUrl) {
return String(baseUrl || DEFAULT_BASE).trim().replace(/\/+$/, '') || DEFAULT_BASE;
}
// 统一请求:拼接 api_key + action + 参数,返回纯文本响应(已 trim)
async function request(cfg, action, params = {}, { timeoutMs = 30000 } = {}) {
const apiKey = String((cfg && cfg.apiKey) || '').trim();
if (!apiKey) {
const e = new Error('未配置 hero-sms API Key(系统设置 → 接码配置)');
e.code = 'NO_API_KEY';
throw e;
}
const q = new URLSearchParams({ api_key: apiKey, action });
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== '') q.set(k, String(v));
}
const url = `${normBase(cfg && cfg.baseUrl)}?${q.toString()}`;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
let res;
try {
res = await fetch(url, { method: 'GET', signal: ctrl.signal });
} catch (err) {
clearTimeout(timer);
const e = new Error(err.name === 'AbortError' ? `接码请求超时(action=${action}` : `接码请求失败:${err.message}`);
e.code = 'NETWORK';
throw e;
}
clearTimeout(timer);
const text = String(await res.text().catch(() => '')).trim();
if (!res.ok) {
const e = new Error(`接码接口错误:HTTP_${res.status}${text ? ' · ' + text.slice(0, 200) : ''}`);
e.code = `HTTP_${res.status}`;
e.status = res.status;
throw e;
}
return text;
}
// 通用错误码 → 带 code 的 ErrorBAD_KEY / BAD_ACTION / BAD_SERVICE / NO_ACTIVATION 等)
function apiError(token, action) {
const map = {
BAD_KEY: 'API Key 无效',
BAD_ACTION: '接口 action 无效',
BAD_SERVICE: '服务代码无效',
NO_ACTIVATION: '激活订单不存在',
NO_BALANCE: '余额不足',
NO_NUMBERS: '当前无可用号码',
};
const e = new Error(`接码接口错误:${token}${map[token] ? ' · ' + map[token] : ''}action=${action}`);
e.code = token;
return e;
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
// ---------- 余额 ----------
// 返回余额数字。响应:ACCESS_BALANCE:<balance>
export async function getBalance(cfg) {
const text = await request(cfg, 'getBalance');
const m = /^ACCESS_BALANCE:(.+)$/.exec(text);
if (m) {
const n = Number(m[1]);
if (Number.isFinite(n)) return n;
}
throw apiError(text || 'EMPTY_RESPONSE', 'getBalance');
}
// ---------- 拿号 ----------
// 返回 { orderId, phone, order },与 smsbower.acquireNumber 一致(orderId = activationId)。
// NO_NUMBERS(无库存,可回退重试)→ e.code='NO_OFFER_AVAILABLE'(与其它平台的库存错误标记一致),
// 并附 e.providerCode='NO_NUMBERS'NO_BALANCE / BAD_KEY / BAD_SERVICE 为硬错误,直接抛出。
export async function acquireNumber(cfg, { log } = {}) {
const say = (level, msg) => { if (typeof log === 'function') log(level, msg); };
const country = cfg.country !== undefined && cfg.country !== null && cfg.country !== '' ? cfg.country : DEFAULT_COUNTRY;
const service = String(cfg.service || DEFAULT_SERVICE);
const params = { service, country };
if (cfg.maxPrice) params.maxPrice = cfg.maxPrice;
if (cfg.minPrice) params.minPrice = cfg.minPrice;
if (cfg.operator) params.operator = cfg.operator; // 指定运营商(如 tim/claro/vivo),换号段规避风控
say('info', `向 hero-sms 请求号码(service=${service} country=${country}${cfg.operator ? ' operator=' + cfg.operator : ''}${cfg.maxPrice ? ' maxPrice=' + cfg.maxPrice : ''})…`);
const text = await request(cfg, 'getNumber', params);
const m = /^ACCESS_NUMBER:([^:]+):(.+)$/.exec(text);
if (m) {
const orderId = m[1];
const phone = m[2];
const order = { id: orderId, phone_number: phone, status: 'PENDING', raw: text };
say('ok', `已获取号码:${phone}(订单 ${orderId}`);
return { orderId, phone, order };
}
if (text === 'NO_NUMBERS' || text.startsWith('NO_NUMBERS')) {
const e = new Error(`当前无可用号码(service=${service} country=${country}`);
e.code = 'NO_OFFER_AVAILABLE';
e.providerCode = 'NO_NUMBERS';
throw e;
}
throw apiError(text || 'EMPTY_RESPONSE', 'getNumber');
}
// ---------- 查询状态 ----------
// 返回 { status, code }。status ∈ WAIT_CODE / WAIT_RETRY / CANCEL / OK
export async function getStatus(cfg, id) {
const text = await request(cfg, 'getStatus', { id });
if (text === 'STATUS_WAIT_CODE') return { status: 'WAIT_CODE', code: '' };
let m = /^STATUS_WAIT_RETRY:(.*)$/.exec(text);
if (m) return { status: 'WAIT_RETRY', code: m[1] };
if (text === 'STATUS_CANCEL') return { status: 'CANCEL', code: '' };
m = /^STATUS_OK:(.+)$/.exec(text);
if (m) return { status: 'OK', code: m[1] };
throw apiError(text || 'EMPTY_RESPONSE', 'getStatus');
}
// ---------- 轮询等待验证码 ----------
// 直到 STATUS_OK 拿到 code,或订单取消/超时抛错。返回 { code, message, order },与其它平台一致。
export async function pollOtp(cfg, id, { timeoutMs = 180000, intervalMs = 4000, log } = {}) {
const start = Date.now();
const say = (level, msg) => { if (typeof log === 'function') log(level, msg); };
while (Date.now() - start < timeoutMs) {
let st;
try {
st = await getStatus(cfg, id);
} catch (e) {
if (e.code === 'NO_ACTIVATION' || e.code === 'BAD_KEY') throw e; // 不可恢复
say('warn', `查询订单失败(将重试):${e.message}`);
await sleep(intervalMs);
continue;
}
if (st.status === 'OK' && st.code) {
say('ok', `已收到验证码:${st.code}`);
const order = { id, status: 'OTP_RECEIVED', otp_code: st.code };
return { code: st.code, message: '', order };
}
if (st.status === 'CANCEL') {
const e = new Error('订单已取消,未收到验证码');
e.code = 'CANCELED';
throw e;
}
say('info', `等待验证码…(订单 ${id} 状态 ${st.status}${st.status === 'WAIT_RETRY' && st.code ? ',上一条验证码 ' + st.code : ''}`);
await sleep(intervalMs);
}
const e = new Error(`等待验证码超时(${Math.round(timeoutMs / 1000)}s`);
e.code = 'OTP_TIMEOUT';
throw e;
}
// ---------- 状态变更 ----------
// setStatus1=已就绪 3=请求下一条验证码 6=完成 8=取消
async function setStatus(cfg, id, status) {
return request(cfg, 'setStatus', { id, status });
}
// 完成订单(收到验证码后调用,setStatus=6)。响应:ACCESS_ACTIVATION
export async function finishOrder(cfg, id) {
const text = await setStatus(cfg, id, 6);
if (text === 'ACCESS_ACTIVATION') return { id, status: 'FINISHED' };
throw apiError(text || 'EMPTY_RESPONSE', 'setStatus(6)');
}
// 取消订单(setStatus=8)。响应:ACCESS_CANCEL。
// EARLY_CANCEL_DENIED(购买 2 分钟内不可取消)按非致命处理:记 warn 后正常返回,号码到期自动退款。
export async function cancelOrder(cfg, id, { log } = {}) {
const say = (level, msg) => { if (typeof log === 'function') log(level, msg); };
const text = await setStatus(cfg, id, 8);
if (text === 'ACCESS_CANCEL') return { id, status: 'CANCELED' };
if (text === 'EARLY_CANCEL_DENIED') {
say('warn', `订单 ${id} 购买未满 2 分钟,暂不可取消(到期后将自动退款)`);
return { id, status: 'CANCEL_PENDING', earlyCancelDenied: true };
}
throw apiError(text || 'EMPTY_RESPONSE', 'setStatus(8)');
}
// 请求下一条验证码(setStatus=3)。响应:ACCESS_RETRY_GET
export async function resendOrder(cfg, id) {
const text = await setStatus(cfg, id, 3);
if (text === 'ACCESS_RETRY_GET') return { id, status: 'WAIT_RETRY' };
throw apiError(text || 'EMPTY_RESPONSE', 'setStatus(3)');
}
+36
View File
@@ -0,0 +1,36 @@
// 查收 wrapper —— 由 Flask /api/plus/inbox 通过 `node inbox_runner.mjs` 调用。
// 输入:stdin 一段 JSON { email, clientId, refreshToken, limit? }
// 输出:stdout 一段 JSON
// 成功 { ok:true, address, via, messages:[...] }
// 失败 { ok:false, status, error }
// 复用 auto 项目原封不动的 plus-mailbox.jsfetchPlusInbox)。
import { fetchPlusInbox } from './plus-mailbox.js';
// 复用的脚本会用 console.log 打印诊断信息(如 plus-mailbox 的“令牌换取成功”),
// 这些默认写 stdout 会污染我们的 JSON 结果。统一改写到 stderr,保证 stdout 只有结果 JSON。
const _errWrite = (...a) => { try { process.stderr.write(a.map(String).join(' ') + '\n'); } catch { /* ignore */ } };
console.log = _errWrite;
console.info = _errWrite;
console.warn = _errWrite;
function readStdin() {
return new Promise((resolve) => {
let buf = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (c) => { buf += c; });
process.stdin.on('end', () => resolve(buf));
});
}
(async () => {
try {
const raw = await readStdin();
const input = JSON.parse(raw || '{}');
const { email, clientId, refreshToken } = input;
const limit = Number(input.limit) || 15;
const r = await fetchPlusInbox({ email, clientId, refreshToken }, limit);
process.stdout.write(JSON.stringify({ ok: true, address: r.address, via: r.via, messages: r.messages || [] }));
} catch (e) {
process.stdout.write(JSON.stringify({ ok: false, status: e && e.status ? e.status : 502, error: (e && e.message) || String(e) }));
}
})();
+137
View File
@@ -0,0 +1,137 @@
{
"name": "account-manager-node",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "account-manager-node",
"dependencies": {
"cloakbrowser": "^0.5.3",
"playwright-core": "^1.62.1",
"undici-legacy": "npm:undici@=7.21.0"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmmirror.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"license": "ISC",
"dependencies": {
"minipass": "^7.0.4"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/cloakbrowser": {
"version": "0.5.3",
"resolved": "https://registry.npmmirror.com/cloakbrowser/-/cloakbrowser-0.5.3.tgz",
"integrity": "sha512-awxjoA3y+id0H9m9278GXftCkgcRcJSWu9D1Oc5E76L063GyMkNbTii8ZCrEQ4zA7b3H3zjRWF+HCtwpHwrsrw==",
"license": "MIT",
"dependencies": {
"tar": "^7.0.0"
},
"bin": {
"cloakbrowser": "dist/cli.js"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"mmdb-lib": ">=2.0.0",
"playwright-core": ">=1.53.0",
"puppeteer-core": ">=21.0.0",
"socks-proxy-agent": ">=10.0.0"
},
"peerDependenciesMeta": {
"mmdb-lib": {
"optional": true
},
"playwright-core": {
"optional": true
},
"puppeteer-core": {
"optional": true
},
"socks-proxy-agent": {
"optional": true
}
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/minizlib": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"license": "MIT",
"dependencies": {
"minipass": "^7.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/tar": {
"version": "7.5.22",
"resolved": "https://registry.npmmirror.com/tar/-/tar-7.5.22.tgz",
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.1.0",
"yallist": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/undici-legacy": {
"name": "undici",
"version": "7.21.0",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "account-manager-node",
"private": true,
"type": "module",
"description": "账号管理 Node 引擎:协议注册(get-rt.js) + 浏览器注册(cloakbrowser)。",
"dependencies": {
"cloakbrowser": "^0.5.3",
"playwright-core": "^1.62.1",
"undici-legacy": "npm:undici@=7.21.0"
}
}
+319
View File
@@ -0,0 +1,319 @@
// PLUS 邮箱收信 —— 微软 Outlook/Hotmail OAuthrefresh_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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/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;
}
}
}
+63
View File
@@ -0,0 +1,63 @@
// 「重拿RT」wrapper —— 对【已存在账号】用 codex 客户端跑一次全新登录(register:false),
// 换取新的 codex refreshToken / accessToken(全程用全新 device_id)。
// 输入(stdin JSON):{ email, password, settings, proxy, otpBase }
// 输出:
// - 过程日志:逐行写 stderr,每行 JSON { level, msg, account }
// - 最终结果:stdout 一段 JSONgetRefreshToken 返回结构:{ ok, refreshToken, accessToken, clientId, deviceId, ... }
import { getRefreshToken } from './get-rt.js';
const _errWrite = (...a) => { try { process.stderr.write(a.map(String).join(' ') + '\n'); } catch { /* ignore */ } };
console.log = _errWrite;
console.info = _errWrite;
console.warn = _errWrite;
function readStdin() {
return new Promise((resolve) => {
let buf = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (c) => { buf += c; });
process.stdin.on('end', () => resolve(buf));
});
}
function emitLog(level, msg, account) {
try { process.stderr.write(JSON.stringify({ level, msg: String(msg), account: account || '' }) + '\n'); } catch { /* ignore */ }
}
(async () => {
let email = '';
try {
const input = JSON.parse((await readStdin()) || '{}');
email = input.email || '';
const settings = input.settings || {};
const proxy = input.proxy || null;
const password = input.password || '';
const otpBase = String(input.otpBase || 'http://127.0.0.1:5088').replace(/\/$/, '');
if (!email) throw new Error('缺少邮箱');
// 邮箱验证码来源:邮箱池(经 Flask /api/reg/inbox)。已注册账号的邮箱在池中,可读到新码。
const fetchInbox = async (account /* , limit */) => {
const addr = (account && account.email) || email;
const url = `${otpBase}/api/reg/inbox?email=${encodeURIComponent(addr)}`;
const res = await fetch(url, { method: 'GET' });
if (!res.ok) throw new Error('读取邮箱失败 HTTP ' + res.status);
const data = await res.json().catch(() => ({}));
return { messages: (data && data.messages) || [] };
};
emitLog('info', '重拿RT中', email);
const result = await getRefreshToken({ email, password }, {
settings,
proxy,
register: false,
password,
log: (level, msg, acc) => emitLog(level, msg, acc || email),
fetchInbox,
});
if (result && result.refreshToken) result.ok = true;
process.stdout.write(JSON.stringify(result || { ok: false, error: '无结果' }));
} catch (e) {
emitLog('error', (e && e.message) || String(e), email);
process.stdout.write(JSON.stringify({ ok: false, error: (e && e.message) || String(e) }));
}
})();
+17
View File
@@ -0,0 +1,17 @@
// 注册密码统一来源 —— 协议模式(reg_runner.mjs) 与 浏览器模式(cloak_reg_runner.mjs) 共用。
//
// 为什么改成「固定/确定性密码」而不是每次随机:
// 随机密码会导致「同一邮箱之前已建号、这次又进来」时用新的随机密码去登录 → OpenAI 报
// "Incorrect email address or password",账号再也拿不到登录态。固定密码让「建号」和
// 「已注册邮箱重进时的登录」始终用同一个口令,也方便导出账密后人工登录。
//
// 优先级:显式传入 input.password > settings.regPassword(用户可在设置里改)> 内置默认强密码。
// 开源版这里仅放一个占位默认口令;请在「设置 → 浏览器 → 注册密码」中填写你自己的强口令,
// 或在真实(被 .gitignore 忽略的)settings.json 里配置 regPassword。
export const DEFAULT_REG_PASSWORD = 'ChangeMe#2026Pw!';
export function resolveRegPassword(input = {}, settings = {}) {
const fromInput = input && typeof input.password === 'string' ? input.password.trim() : '';
const fromSettings = settings && typeof settings.regPassword === 'string' ? settings.regPassword.trim() : '';
return fromInput || fromSettings || DEFAULT_REG_PASSWORD;
}
+68
View File
@@ -0,0 +1,68 @@
// 协议注册 wrapper —— 复用 get-rt.js 的无密码 OAuth 协议流程(步骤1~6),
// 但 OTP 不再从 Outlook 读,而是经 Flask /api/reg/inbox 从「邮箱池」读取(IMAP / 取件API)。
// 输入(stdin JSON):{ email, settings, proxy, otpBase }
// 输出:
// - 过程日志:逐行写 stderr,每行 JSON { level, msg, account }
// - 最终结果:stdout 一段 JSONgetRefreshToken 返回结构)
// 成功 { ok:true, refreshToken, clientId, accessToken, idToken }
// 失败 { ok:false, banned?, reason?, error }
import { getRefreshToken } from './get-rt.js';
import { resolveRegPassword } from './reg_password.mjs';
// get-rt.js / plus-mailbox.js 里的 console.* 默认写 stdout,会污染结果 JSON,统一改到 stderr。
const _errWrite = (...a) => { try { process.stderr.write(a.map(String).join(' ') + '\n'); } catch { /* ignore */ } };
console.log = _errWrite;
console.info = _errWrite;
console.warn = _errWrite;
function readStdin() {
return new Promise((resolve) => {
let buf = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (c) => { buf += c; });
process.stdin.on('end', () => resolve(buf));
});
}
function emitLog(level, msg, account) {
try { process.stderr.write(JSON.stringify({ level, msg: String(msg), account: account || '' }) + '\n'); } catch { /* ignore */ }
}
(async () => {
let email = '';
try {
const input = JSON.parse((await readStdin()) || '{}');
email = input.email || '';
const settings = input.settings || {};
const proxy = input.proxy || null;
const otpBase = String(input.otpBase || 'http://127.0.0.1:5088').replace(/\/$/, '');
if (!email) throw new Error('缺少邮箱');
// 从邮箱池(经 Flask)读取收件箱,形状与 plus-mailbox 一致:
// { messages: [{ from_addr, subject, body, received_at, extracted_code }] }
const fetchInbox = async (account /* , limit */) => {
const addr = (account && account.email) || email;
const url = `${otpBase}/api/reg/inbox?email=${encodeURIComponent(addr)}`;
const res = await fetch(url, { method: 'GET' });
if (!res.ok) throw new Error('读取邮箱失败 HTTP ' + res.status);
const data = await res.json().catch(() => ({}));
return { messages: (data && data.messages) || [] };
};
const password = resolveRegPassword(input, settings);
const result = await getRefreshToken({ email }, {
settings,
proxy,
register: true,
password,
log: (level, msg, acc) => emitLog(level, msg, acc || email),
fetchInbox,
});
// getRefreshToken 成功仅返回 {refreshToken,...},补 ok:true 供上游判断,并回传密码用于落库/导出账密
if (result && result.refreshToken) { result.ok = true; result.password = password; }
process.stdout.write(JSON.stringify(result));
} catch (e) {
emitLog('error', (e && e.message) || String(e), email);
process.stdout.write(JSON.stringify({ ok: false, banned: false, error: (e && e.message) || String(e) }));
}
})();
+50
View File
@@ -0,0 +1,50 @@
// 拿 RT wrapper —— 由 Flask /api/plus/get-rt 编排逐个账号通过 `node rt_runner.mjs` 调用。
// 输入:stdin 一段 JSON { account:{email,password,clientId,refreshToken}, settings, proxy }
// 输出:
// - 过程日志:逐行写 stderr,每行是 JSON { level, msg, account }(供 Flask 汇集/落日志)
// - 最终结果:stdout 一段 JSONgetRefreshToken 的返回结构)
// 成功 { ok:true, refreshToken, clientId, accessToken, idToken }
// 封号 { ok:false, banned:true, reason, error }
// 失败 { ok:false, banned:false, error }
// 复用 auto 项目原封不动的 get-rt.jsgetRefreshToken)。
import { getRefreshToken } from './get-rt.js';
// 复用脚本内部的 console.log/info/warn(如 plus-mailbox 的诊断行)默认写 stdout
// 会污染我们最终的结果 JSON。统一改写到 stderr,保证 stdout 只有结果 JSON。
const _errWrite = (...a) => { try { process.stderr.write(a.map(String).join(' ') + '\n'); } catch { /* ignore */ } };
console.log = _errWrite;
console.info = _errWrite;
console.warn = _errWrite;
function readStdin() {
return new Promise((resolve) => {
let buf = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (c) => { buf += c; });
process.stdin.on('end', () => resolve(buf));
});
}
function emitLog(level, msg, account) {
try { process.stderr.write(JSON.stringify({ level, msg: String(msg), account: account || '' }) + '\n'); } catch { /* ignore */ }
}
(async () => {
let account = {};
try {
const raw = await readStdin();
const input = JSON.parse(raw || '{}');
account = input.account || {};
const settings = input.settings || {};
const proxy = input.proxy || null;
const result = await getRefreshToken(account, {
settings,
proxy,
log: (level, msg, acc) => emitLog(level, msg, acc || account.email),
});
process.stdout.write(JSON.stringify(result));
} catch (e) {
emitLog('error', (e && e.message) || String(e), account.email);
process.stdout.write(JSON.stringify({ ok: false, banned: false, error: (e && e.message) || String(e) }));
}
})();
+204
View File
@@ -0,0 +1,204 @@
// 接码 / SMS 验证码服务 —— smsbower.app API 客户端(零依赖,使用全局 fetch)
// 协议:经典 sms-activate 风格,所有请求为 GET https://smsbower.page/stubs/handler_api.php?api_key=..&action=..
// 响应为纯文本(冒号分隔),如 ACCESS_NUMBER:<activationId>:<phone>、STATUS_OK:<code>
//
// 与 smscode.js 接口对齐(可作为调度器的替换供应商):
// acquireNumber(cfg, { log }) → { orderId, phone, order }
// pollOtp(cfg, id, { timeoutMs, intervalMs, log }) → { code, message, order }
// finishOrder(cfg, id) → setStatus status=6(完成)
// cancelOrder(cfg, id) → setStatus status=8(取消,2 分钟内不可取消时静默处理)
// getBalance(cfg) → 余额数字
//
// 配置字段:apiKey、baseUrl(默认 .page 地址)、country(默认 22=印度)、
// service(默认 "dr"=OpenAI/ChatGPT)、maxPrice、pollTimeout
const DEFAULT_BASE = 'https://smsbower.page/stubs/handler_api.php';
const DEFAULT_COUNTRY = 22; // 印度
const DEFAULT_SERVICE = 'dr'; // OpenAI / ChatGPT
function normBase(baseUrl) {
return String(baseUrl || DEFAULT_BASE).trim().replace(/\/+$/, '') || DEFAULT_BASE;
}
// 统一请求:拼接 api_key + action + 参数,返回纯文本响应(已 trim)
async function request(cfg, action, params = {}, { timeoutMs = 30000 } = {}) {
const apiKey = String((cfg && cfg.apiKey) || '').trim();
if (!apiKey) {
const e = new Error('未配置 smsbower API Key(系统设置 → 接码配置)');
e.code = 'NO_API_KEY';
throw e;
}
const q = new URLSearchParams({ api_key: apiKey, action });
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== '') q.set(k, String(v));
}
const url = `${normBase(cfg && cfg.baseUrl)}?${q.toString()}`;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
let res;
try {
res = await fetch(url, { method: 'GET', signal: ctrl.signal });
} catch (err) {
clearTimeout(timer);
const e = new Error(err.name === 'AbortError' ? `接码请求超时(action=${action}` : `接码请求失败:${err.message}`);
e.code = 'NETWORK';
throw e;
}
clearTimeout(timer);
const text = String(await res.text().catch(() => '')).trim();
if (!res.ok) {
const e = new Error(`接码接口错误:HTTP_${res.status}${text ? ' · ' + text.slice(0, 200) : ''}`);
e.code = `HTTP_${res.status}`;
e.status = res.status;
throw e;
}
return text;
}
// 通用错误码 → 带 code 的 ErrorBAD_KEY / BAD_ACTION / BAD_SERVICE / NO_ACTIVATION 等)
function apiError(token, action) {
const map = {
BAD_KEY: 'API Key 无效',
BAD_ACTION: '接口 action 无效',
BAD_SERVICE: '服务代码无效',
NO_ACTIVATION: '激活订单不存在',
NO_BALANCE: '余额不足',
NO_NUMBERS: '当前无可用号码',
};
const e = new Error(`接码接口错误:${token}${map[token] ? ' · ' + map[token] : ''}action=${action}`);
e.code = token;
return e;
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
// ---------- 余额 ----------
// 返回余额数字。响应:ACCESS_BALANCE:<balance>
export async function getBalance(cfg) {
const text = await request(cfg, 'getBalance');
const m = /^ACCESS_BALANCE:(.+)$/.exec(text);
if (m) {
const n = Number(m[1]);
if (Number.isFinite(n)) return n;
}
throw apiError(text || 'EMPTY_RESPONSE', 'getBalance');
}
// ---------- 拿号 ----------
// 返回 { orderId, phone, order },与 smscode.acquireNumber 一致(orderId = activationId)。
// NO_NUMBERS(无库存,可回退重试)→ e.code='NO_OFFER_AVAILABLE'(与 smscode 的库存错误标记一致),
// 并附 e.providerCode='NO_NUMBERS'NO_BALANCE / BAD_KEY / BAD_SERVICE 为硬错误,直接抛出。
export async function acquireNumber(cfg, { log } = {}) {
const say = (level, msg) => { if (typeof log === 'function') log(level, msg); };
const country = cfg.country !== undefined && cfg.country !== null && cfg.country !== '' ? cfg.country : DEFAULT_COUNTRY;
const service = String(cfg.service || DEFAULT_SERVICE);
const params = { service, country };
if (cfg.maxPrice) params.maxPrice = cfg.maxPrice;
if (cfg.minPrice) params.minPrice = cfg.minPrice;
say('info', `向 smsbower 请求号码(service=${service} country=${country}${cfg.maxPrice ? ' maxPrice=' + cfg.maxPrice : ''})…`);
const text = await request(cfg, 'getNumber', params);
const m = /^ACCESS_NUMBER:([^:]+):(.+)$/.exec(text);
if (m) {
const orderId = m[1];
const phone = m[2];
const order = { id: orderId, phone_number: phone, status: 'PENDING', raw: text };
say('ok', `已获取号码:${phone}(订单 ${orderId}`);
return { orderId, phone, order };
}
if (text === 'NO_NUMBERS' || text.startsWith('NO_NUMBERS')) {
// 无库存:标记为可回退的库存错误(与 smscode 的 isStockError 判定一致)
const e = new Error(`当前无可用号码(service=${service} country=${country}`);
e.code = 'NO_OFFER_AVAILABLE';
e.providerCode = 'NO_NUMBERS';
throw e;
}
throw apiError(text || 'EMPTY_RESPONSE', 'getNumber');
}
// ---------- 查询状态 ----------
// 返回 { status, code }。status ∈ WAIT_CODE / WAIT_RETRY / CANCEL / OK
export async function getStatus(cfg, id) {
const text = await request(cfg, 'getStatus', { id });
if (text === 'STATUS_WAIT_CODE') return { status: 'WAIT_CODE', code: '' };
let m = /^STATUS_WAIT_RETRY:(.*)$/.exec(text);
if (m) return { status: 'WAIT_RETRY', code: m[1] };
if (text === 'STATUS_CANCEL') return { status: 'CANCEL', code: '' };
m = /^STATUS_OK:(.+)$/.exec(text);
if (m) return { status: 'OK', code: m[1] };
throw apiError(text || 'EMPTY_RESPONSE', 'getStatus');
}
// ---------- 轮询等待验证码 ----------
// 直到 STATUS_OK 拿到 code,或订单取消/超时抛错。返回 { code, message, order },与 smscode.pollOtp 一致。
export async function pollOtp(cfg, id, { timeoutMs = 180000, intervalMs = 4000, log } = {}) {
const start = Date.now();
const say = (level, msg) => { if (typeof log === 'function') log(level, msg); };
while (Date.now() - start < timeoutMs) {
let st;
try {
st = await getStatus(cfg, id);
} catch (e) {
if (e.code === 'NO_ACTIVATION' || e.code === 'BAD_KEY') throw e; // 不可恢复
say('warn', `查询订单失败(将重试):${e.message}`);
await sleep(intervalMs);
continue;
}
if (st.status === 'OK' && st.code) {
say('ok', `已收到验证码:${st.code}`);
const order = { id, status: 'OTP_RECEIVED', otp_code: st.code };
return { code: st.code, message: '', order };
}
if (st.status === 'CANCEL') {
const e = new Error('订单已取消,未收到验证码');
e.code = 'CANCELED';
throw e;
}
// WAIT_CODE / WAIT_RETRY:继续等待
say('info', `等待验证码…(订单 ${id} 状态 ${st.status}${st.status === 'WAIT_RETRY' && st.code ? ',上一条验证码 ' + st.code : ''}`);
await sleep(intervalMs);
}
const e = new Error(`等待验证码超时(${Math.round(timeoutMs / 1000)}s`);
e.code = 'OTP_TIMEOUT';
throw e;
}
// ---------- 状态变更 ----------
// setStatus1=已就绪 3=请求下一条验证码 6=完成 8=取消
async function setStatus(cfg, id, status) {
return request(cfg, 'setStatus', { id, status });
}
// 完成订单(收到验证码后调用,setStatus=6)。响应:ACCESS_ACTIVATION
export async function finishOrder(cfg, id) {
const text = await setStatus(cfg, id, 6);
if (text === 'ACCESS_ACTIVATION') return { id, status: 'FINISHED' };
throw apiError(text || 'EMPTY_RESPONSE', 'setStatus(6)');
}
// 取消订单(setStatus=8)。响应:ACCESS_CANCEL。
// EARLY_CANCEL_DENIED(购买 2 分钟内不可取消)按非致命处理:记 warn 后正常返回,号码到期自动退款。
export async function cancelOrder(cfg, id, { log } = {}) {
const say = (level, msg) => { if (typeof log === 'function') log(level, msg); };
const text = await setStatus(cfg, id, 8);
if (text === 'ACCESS_CANCEL') return { id, status: 'CANCELED' };
if (text === 'EARLY_CANCEL_DENIED') {
say('warn', `订单 ${id} 购买未满 2 分钟,暂不可取消(到期后将自动退款)`);
return { id, status: 'CANCEL_PENDING', earlyCancelDenied: true };
}
throw apiError(text || 'EMPTY_RESPONSE', 'setStatus(8)');
}
// 请求下一条验证码(setStatus=3),对应 smscode.resendOrder。响应:ACCESS_RETRY_GET
export async function resendOrder(cfg, id) {
const text = await setStatus(cfg, id, 3);
if (text === 'ACCESS_RETRY_GET') return { id, status: 'WAIT_RETRY' };
throw apiError(text || 'EMPTY_RESPONSE', 'setStatus(3)');
}
+241
View File
@@ -0,0 +1,241 @@
// 接码 / SMS 验证码服务 —— smscode.gg API 客户端(零依赖,使用全局 fetch)
// 文档:https://smscode.gg/docs Base URL: https://api.smscode.gg/v1
// 鉴权:Authorization: Bearer <apiKey>
// 统一响应包络:{ success:true, data:{...} } / { success:false, error:{ code, message } }
//
// 订单生命周期:
// 1. createOrder → 下单拿号(product_id 精确档位 或 catalog_product_id 路由)
// 2. pollOtp → 轮询 GET /orders/{id} 直到 status=OTP_RECEIVED 拿到 otp_code
// 3. finishOrder → 收到验证码后标记完成(立即释放号码)
// 或 cancelOrder → 未收到验证码时取消(退款)
const DEFAULT_BASE = 'https://api.smscode.gg/v1';
function normBase(baseUrl) {
return String(baseUrl || DEFAULT_BASE).trim().replace(/\/+$/, '') || DEFAULT_BASE;
}
// 统一请求:注入 Bearer、解析包络、处理 429/超时/HTTP 错误
async function request(cfg, method, pathAndQuery, { body, idempotencyKey, timeoutMs = 30000 } = {}) {
const base = normBase(cfg && cfg.baseUrl);
const apiKey = String((cfg && cfg.apiKey) || '').trim();
if (!apiKey) {
const e = new Error('未配置接码 API Key(系统设置 → 接码配置)');
e.code = 'NO_API_KEY';
throw e;
}
const url = `${base}${pathAndQuery}`;
const headers = {
authorization: `Bearer ${apiKey}`,
accept: 'application/json',
};
if (body !== undefined) headers['content-type'] = 'application/json';
if (idempotencyKey) headers['idempotency-key'] = idempotencyKey;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
let res;
try {
res = await fetch(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: ctrl.signal,
});
} catch (err) {
clearTimeout(timer);
const e = new Error(err.name === 'AbortError' ? `接码请求超时(${method} ${pathAndQuery}` : `接码请求失败:${err.message}`);
e.code = 'NETWORK';
throw e;
}
clearTimeout(timer);
if (res.status === 429) {
const retry = Number(res.headers.get('retry-after')) || 5;
const e = new Error(`接码接口限流(429),请 ${retry}s 后重试`);
e.code = 'RATE_LIMIT_EXCEEDED';
e.retryAfter = retry;
throw e;
}
const data = await res.json().catch(() => ({}));
if (!res.ok || data.success === false) {
const err = (data && data.error) || {};
const e = new Error(`接码接口错误:${err.code || res.status}${err.message ? ' · ' + err.message : ''}`);
e.code = err.code || `HTTP_${res.status}`;
e.status = res.status;
throw e;
}
return data.data;
}
// ---------- 目录(catalog ----------
export async function listCountries(cfg) {
return request(cfg, 'GET', '/catalog/countries');
}
export async function listServices(cfg, countryId) {
const q = countryId ? `?country_id=${encodeURIComponent(countryId)}` : '';
return request(cfg, 'GET', `/catalog/services${q}`);
}
export async function listProducts(cfg, { countryId, platformId, operatorId, limit = 50, page = 1 } = {}) {
const p = new URLSearchParams();
if (countryId) p.set('country_id', countryId);
if (platformId) p.set('platform_id', platformId);
if (operatorId) p.set('operator_id', operatorId);
p.set('limit', limit);
p.set('page', page);
return request(cfg, 'GET', `/catalog/products?${p.toString()}`);
}
export async function getBalance(cfg) {
return request(cfg, 'GET', '/balance');
}
// ---------- 订单 ----------
// 下单拿号。优先使用 productId(精确档位),否则用 catalogProductId(路由下单)。
export async function createOrder(cfg, { productId, catalogProductId, operatorId, maxPrice, quantity = 1 } = {}) {
const body = { quantity };
if (productId) body.product_id = Number(productId);
else if (catalogProductId) {
body.catalog_product_id = Number(catalogProductId);
if (operatorId) body.operator_id = Number(operatorId);
if (maxPrice) body.max_price = /^\d+$/.test(String(maxPrice)) ? Number(maxPrice) : String(maxPrice);
} else {
const e = new Error('下单需提供 productId 或 catalogProductId');
e.code = 'VALIDATION_ERROR';
throw e;
}
const idempotencyKey = `getrt-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const data = await request(cfg, 'POST', '/orders/create', { body, idempotencyKey });
const order = (data.orders && data.orders[0]) || data;
if (!order || !order.id) {
const e = new Error('下单未返回有效订单');
e.code = 'NO_ORDER';
throw e;
}
return order; // { id, status, phone_number, ... }
}
export async function getOrder(cfg, id) {
return request(cfg, 'GET', `/orders/${encodeURIComponent(id)}`);
}
export async function finishOrder(cfg, id) {
return request(cfg, 'POST', '/orders/finish', { body: { id: Number(id) } });
}
export async function cancelOrder(cfg, id) {
return request(cfg, 'POST', '/orders/cancel', { body: { id: Number(id) } });
}
export async function resendOrder(cfg, id) {
return request(cfg, 'POST', '/orders/resend', { body: { id: Number(id) } });
}
// 轮询等待验证码:直到 status=OTP_RECEIVED 且拿到 otp_code,或超时。
// log(level,msg) 可选,用于向上层推送进度。
export async function pollOtp(cfg, id, { timeoutMs = 180000, intervalMs = 4000, log } = {}) {
const start = Date.now();
const say = (level, msg) => { if (typeof log === 'function') log(level, msg); };
while (Date.now() - start < timeoutMs) {
let order;
try {
order = await getOrder(cfg, id);
} catch (e) {
say('warn', `查询订单失败(将重试):${e.message}`);
await sleep(intervalMs);
continue;
}
const status = String(order.status || '').toUpperCase();
if (order.otp_code) {
say('ok', `已收到验证码:${order.otp_code}`);
return { code: order.otp_code, message: order.otp_message || '', order };
}
if (status === 'CANCELED' || status === 'EXPIRED') {
const e = new Error(`订单已${status === 'CANCELED' ? '取消' : '过期'},未收到验证码`);
e.code = status;
throw e;
}
say('info', `等待验证码…(订单 ${id} 状态 ${status || '未知'}`);
await sleep(intervalMs);
}
const e = new Error(`等待验证码超时(${Math.round(timeoutMs / 1000)}s`);
e.code = 'OTP_TIMEOUT';
throw e;
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
// 一站式:拿号 → 轮询验证码。返回 { orderId, phone, code }。
// 判断下单失败是否属于“无库存/无号码”类(可回退下一档位),而非鉴权/余额类(应立即抛出)。
function isStockError(e) {
const code = String((e && e.code) || '').toUpperCase();
if (['NO_OFFER_AVAILABLE', 'PROVIDER_ERROR', 'NO_ORDER'].includes(code)) return true;
const msg = String((e && e.message) || '').toLowerCase();
if ((e && e.status === 422) && /(no[_\s-]?number|no[_\s-]?offer|out.?of.?stock|no.?stock|stock|available|sold.?out)/.test(msg)) return true;
return false;
}
// 拉取 country+service 的产品档位,构造“有货 + 按价格升序”的候选下单顺序。
// 若配置了 productId 且该档位有货,则置于队首。返回 [{ productId, price, available }]。
export async function buildCandidates(cfg, { log } = {}) {
const say = (level, msg) => { if (typeof log === 'function') log(level, msg); };
const products = await listProducts(cfg, { countryId: cfg.countryId, platformId: cfg.serviceId, limit: 10000, page: 1 });
const list = Array.isArray(products) ? products : [];
const inStock = list
.filter((p) => p && p.active !== false && (Number(p.available) || 0) > 0)
.map((p) => ({ productId: p.id, price: Number(p.price) || 0, available: Number(p.available) || 0 }))
.sort((a, b) => a.price - b.price);
const configured = Number(cfg.productId) || 0;
let ordered = inStock;
if (configured) {
const hit = inStock.find((c) => Number(c.productId) === configured);
if (hit) ordered = [hit, ...inStock.filter((c) => Number(c.productId) !== configured)];
else say('warn', `配置的 productId=${configured} 当前无货,改用有货档位(按价格升序)`);
}
say('info', `候选档位(有货,共 ${ordered.length}):${ordered.slice(0, 8).map((c) => `#${c.productId}@${c.price}(${c.available})`).join(' → ')}${ordered.length > 8 ? ' …' : ''}`);
return ordered;
}
// 拿号(带无货自动回退)。返回 { orderId, phone, order },与旧签名一致。
// 调用方应在使用完验证码后调用 finishOrder;失败时调用 cancelOrder(若 can_cancel)。
export async function acquireNumber(cfg, { log } = {}) {
const say = (level, msg) => { if (typeof log === 'function') log(level, msg); };
// 未配置 country+service:维持旧行为(单一 productId / catalogProductId)。
if (!cfg.countryId || !cfg.serviceId) {
const order = await createOrder(cfg, {
productId: cfg.productId,
catalogProductId: cfg.catalogProductId,
operatorId: cfg.operatorId,
maxPrice: cfg.maxPrice,
});
say('ok', `已获取号码:${order.phone_number}(订单 ${order.id}`);
return { orderId: order.id, phone: order.phone_number, order };
}
const candidates = await buildCandidates(cfg, { log });
if (!candidates.length) {
const e = new Error(`所有档位均无库存/下单失败(country=${cfg.countryId} service=${cfg.serviceId}`);
e.code = 'NO_OFFER_AVAILABLE';
throw e;
}
for (let i = 0; i < candidates.length; i++) {
const c = candidates[i];
try {
say('info', `尝试下单档位 #${c.productId}(价格 ${c.price} IDR,库存 ${c.available}`);
const order = await createOrder(cfg, { productId: c.productId });
say('ok', `已获取号码:${order.phone_number}(订单 ${order.id},档位 #${c.productId} 价格 ${c.price} IDR`);
return { orderId: order.id, phone: order.phone_number, order };
} catch (e) {
if (isStockError(e)) {
say('warn', `档位 #${c.productId} 无库存/下单失败(${e.code || e.message}),尝试下一档位…`);
continue;
}
throw e; // 鉴权/余额等非库存错误:立即抛出
}
}
const e = new Error(`所有档位均无库存/下单失败(country=${cfg.countryId} service=${cfg.serviceId}`);
e.code = 'NO_OFFER_AVAILABLE';
throw e;
}