mirror of
https://wget.la/https://github.com/432539/gpt
synced 2026-08-17 03:26:59 +08:00
Initial commit: GPT 账号注册机 (open source)
只开源「GPT 账号注册机 / 管理」(account-manager):账号列表/导入导出、检测/批量检测、 拿 RT、协议/浏览器注册等注册机能力。批量直开 / 协议支付 / 提取链接 / 直卡直开 等其它功能 不含实现代码,仅通过 iframe 内嵌外链访问。真实机密均由 .gitignore 忽略,仅提供脱敏示例配置。
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
// 反检测:为每个注册账号合成一套「内部自洽」的浏览器/设备指纹 + 随机姓名 + 成年生日。
|
||||
// 目的:以前所有账号都写死同一 UA(Chrome/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
@@ -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=22,OpenAI 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 的 Error(BAD_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;
|
||||
}
|
||||
|
||||
// ---------- 状态变更 ----------
|
||||
// setStatus:1=已就绪 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)');
|
||||
}
|
||||
@@ -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.js(fetchPlusInbox)。
|
||||
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) }));
|
||||
}
|
||||
})();
|
||||
Generated
+137
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 一段 JSON(getRefreshToken 返回结构:{ 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) }));
|
||||
}
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 一段 JSON(getRefreshToken 返回结构)
|
||||
// 成功 { 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) }));
|
||||
}
|
||||
})();
|
||||
@@ -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 一段 JSON(getRefreshToken 的返回结构)
|
||||
// 成功 { ok:true, refreshToken, clientId, accessToken, idToken }
|
||||
// 封号 { ok:false, banned:true, reason, error }
|
||||
// 失败 { ok:false, banned:false, error }
|
||||
// 复用 auto 项目原封不动的 get-rt.js(getRefreshToken)。
|
||||
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) }));
|
||||
}
|
||||
})();
|
||||
@@ -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 的 Error(BAD_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;
|
||||
}
|
||||
|
||||
// ---------- 状态变更 ----------
|
||||
// setStatus:1=已就绪 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)');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user