feat: add smsbower phone fill tool with rotation
This commit is contained in:
+301
@@ -0,0 +1,301 @@
|
||||
const DEFAULT_SETTINGS = Object.freeze({
|
||||
apiKey: '',
|
||||
baseUrl: 'https://smsbower.app/stubs/handler_api.php',
|
||||
serviceCode: 'dr',
|
||||
minPrice: '',
|
||||
maxPrice: '',
|
||||
countries: [{ id: 6, label: 'Indonesia' }],
|
||||
activeActivation: null,
|
||||
});
|
||||
|
||||
const FALLBACK_COUNTRIES = Object.freeze([
|
||||
{ id: 0, label: 'Russia' }, { id: 1, label: 'Ukraine' }, { id: 2, label: 'Kazakhstan' },
|
||||
{ id: 3, label: 'China' }, { id: 4, label: 'Philippines' }, { id: 5, label: 'Myanmar' },
|
||||
{ id: 6, label: 'Indonesia' }, { id: 7, label: 'Malaysia' }, { id: 10, label: 'Vietnam' },
|
||||
{ id: 12, label: 'USA virtual' }, { id: 16, label: 'United Kingdom' }, { id: 22, label: 'India' },
|
||||
{ id: 32, label: 'Romania' }, { id: 36, label: 'Canada' }, { id: 43, label: 'Germany' },
|
||||
{ id: 52, label: 'Thailand' }, { id: 73, label: 'France' }, { id: 78, label: 'Turkey' },
|
||||
{ id: 151, label: 'Japan' }, { id: 187, label: 'USA' }
|
||||
]);
|
||||
|
||||
function asText(value = '', fallback = '') { return String(value ?? '').trim() || fallback; }
|
||||
function normalizeCountryId(value, fallback = 6) {
|
||||
const parsed = Math.floor(Number(value));
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
}
|
||||
function normalizePrice(value) {
|
||||
if (value === undefined || value === null || String(value).trim() === '') return null;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? Math.round(n * 10000) / 10000 : null;
|
||||
}
|
||||
function normalizeFailureReason(value = '') {
|
||||
return String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') || 'manual_replace';
|
||||
}
|
||||
function isPhoneNumberUsedError(value) {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) return false;
|
||||
return /phone_max_usage_exceeded|phone_number_in_use|already\s+linked\s+to\s+the\s+maximum\s+number\s+of\s+accounts|phone\s+number\s+is\s+already\s+(?:in\s+use|linked|registered)|phone\s+number\s+has\s+already\s+been\s+used|already\s+associated\s+with\s+another\s+account|not\s+eligible\s+to\s+be\s+used|cannot\s+be\s+used\s+for\s+verification|号码.*(?:已|被).*(?:使用|占用|绑定|注册)|手机号.*(?:已|被).*(?:使用|占用|绑定|注册)|电话.*(?:已|被).*(?:使用|占用|绑定|注册)|该手机号.*(?:已|被).*(?:使用|占用|绑定|注册)|该电话.*(?:已|被).*(?:使用|占用|绑定|注册)/i.test(text);
|
||||
}
|
||||
function isPhoneNumberDeliveryRefusedError(value) {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) return false;
|
||||
return /无法向此电话号码发送验证码|无法向.*(?:电话号码|手机号|号码).*发送(?:验证码|短信)|(?:不能|无法).*发送.*(?:验证码|短信).*(?:电话号码|手机号|号码)|(?:cannot|can't|could\s*not|couldn't|unable\s+to)\s+(?:send|deliver).{0,80}(?:verification\s+code|code|sms|text(?:\s+message)?).{0,80}(?:phone|number)|(?:verification\s+code|sms|text(?:\s+message)?).{0,80}(?:cannot|can't|could\s*not|couldn't|unable\s+to).{0,80}(?:send|deliver)/i.test(text);
|
||||
}
|
||||
function getPhoneReplacementReleaseAction(reason = '') {
|
||||
const normalized = normalizeFailureReason(reason);
|
||||
if (normalized === 'code_rejected' || normalized === 'phone_number_used' || normalized === 'phone_number_in_use' || normalized === 'phone_max_usage_exceeded' || isPhoneNumberUsedError(reason)) {
|
||||
return 'ban';
|
||||
}
|
||||
return 'cancel';
|
||||
}
|
||||
function normalizeSettings(input = {}) {
|
||||
const countries = Array.isArray(input.countries) && input.countries.length
|
||||
? input.countries.map((c) => ({ id: normalizeCountryId(c.id ?? c.country, 6), label: asText(c.label, `Country #${normalizeCountryId(c.id ?? c.country, 6)}`) }))
|
||||
: DEFAULT_SETTINGS.countries;
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...input,
|
||||
apiKey: asText(input.apiKey),
|
||||
baseUrl: asText(input.baseUrl, DEFAULT_SETTINGS.baseUrl),
|
||||
serviceCode: asText(input.serviceCode, DEFAULT_SETTINGS.serviceCode).toLowerCase().replace(/[^a-z0-9_-]+/g, '') || DEFAULT_SETTINGS.serviceCode,
|
||||
minPrice: normalizePrice(input.minPrice) === null ? '' : String(normalizePrice(input.minPrice)),
|
||||
maxPrice: normalizePrice(input.maxPrice) === null ? '' : String(normalizePrice(input.maxPrice)),
|
||||
countries,
|
||||
activeActivation: input.activeActivation && typeof input.activeActivation === 'object' ? input.activeActivation : null,
|
||||
};
|
||||
}
|
||||
async function getSettings() {
|
||||
const stored = await chrome.storage.local.get(Object.keys(DEFAULT_SETTINGS));
|
||||
return normalizeSettings(stored);
|
||||
}
|
||||
async function saveSettings(update = {}) {
|
||||
const next = normalizeSettings({ ...(await getSettings()), ...update });
|
||||
await chrome.storage.local.set(next);
|
||||
return next;
|
||||
}
|
||||
function buildUrl(settings, query = {}) {
|
||||
const url = new URL(settings.baseUrl || DEFAULT_SETTINGS.baseUrl);
|
||||
if (settings.apiKey) url.searchParams.set('api_key', settings.apiKey);
|
||||
Object.entries(query).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
|
||||
});
|
||||
return url.toString();
|
||||
}
|
||||
function parsePayload(text) {
|
||||
const t = String(text || '').trim();
|
||||
if (!t) return '';
|
||||
if ((t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'))) {
|
||||
try { return JSON.parse(t); } catch {}
|
||||
}
|
||||
return t;
|
||||
}
|
||||
function describePayload(payload) {
|
||||
if (typeof payload === 'string') return payload.trim();
|
||||
if (payload && typeof payload === 'object') {
|
||||
const direct = payload.message || payload.msg || payload.error || payload.title || payload.status;
|
||||
if (direct) return String(direct).trim();
|
||||
try { return JSON.stringify(payload); } catch {}
|
||||
}
|
||||
return String(payload || '').trim();
|
||||
}
|
||||
async function apiRequest(settings, query, label = 'SMSBower 请求') {
|
||||
if (!settings.apiKey) throw new Error('请先填写 SMSBower API Key。');
|
||||
const response = await fetch(buildUrl(settings, query), { method: 'GET', headers: { Accept: 'application/json, text/plain, */*' } });
|
||||
const payload = parsePayload(await response.text());
|
||||
const message = describePayload(payload);
|
||||
const apiStatus = payload && typeof payload === 'object' && !Array.isArray(payload) ? Number(payload.status) : null;
|
||||
if (!response.ok || apiStatus === 0 || /^(BAD_KEY|BAD_ACTION|BAD_SERVICE|BAD_STATUS|NO_ACTIVATION|EARLY_CANCEL_DENIED|BAD_COUNTRY)\b/i.test(message)) {
|
||||
throw new Error(`${label}失败:${message || response.status}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
function normalizeActivation(payload, fallback = {}) {
|
||||
if (typeof payload === 'string') {
|
||||
const m = payload.match(/^ACCESS_NUMBER\s*:\s*([^:]+)\s*:\s*(.+)$/i);
|
||||
if (m) payload = { activationId: m[1], phoneNumber: m[2] };
|
||||
}
|
||||
const src = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {};
|
||||
const activationId = asText(src.activationId ?? src.activation_id ?? src.id ?? fallback.activationId);
|
||||
const phoneNumber = asText(src.phoneNumber ?? src.phone_number ?? src.phone ?? src.number ?? fallback.phoneNumber);
|
||||
if (!activationId || !phoneNumber) return null;
|
||||
const countryId = normalizeCountryId(src.countryId ?? src.country_id ?? src.country ?? fallback.countryId, fallback.countryId ?? 6);
|
||||
return {
|
||||
activationId,
|
||||
phoneNumber,
|
||||
provider: 'sms-bower',
|
||||
serviceCode: asText(src.serviceCode ?? fallback.serviceCode, fallback.serviceCode || DEFAULT_SETTINGS.serviceCode),
|
||||
countryId,
|
||||
countryLabel: asText(src.countryLabel ?? fallback.countryLabel, fallback.countryLabel || `Country #${countryId}`),
|
||||
price: Number.isFinite(Number(src.price ?? fallback.price)) ? Number(src.price ?? fallback.price) : null,
|
||||
acquiredAt: Date.now(),
|
||||
};
|
||||
}
|
||||
function collectPriceEntries(payload, entries = []) {
|
||||
if (Array.isArray(payload)) { payload.forEach((x) => collectPriceEntries(x, entries)); return entries; }
|
||||
if (!payload || typeof payload !== 'object') return entries;
|
||||
const count = Number(payload.count ?? payload.qty ?? payload.Qty);
|
||||
const price = Number(payload.cost ?? payload.price ?? payload.Price);
|
||||
if (Number.isFinite(price) && price >= 0) entries.push({ cost: price, count: Number.isFinite(count) ? count : 0, inStock: !Number.isFinite(count) || count > 0 });
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
const keyedPrice = Number(key);
|
||||
const keyedCount = Number(value.count ?? value.qty ?? value.Qty);
|
||||
if (/^\d+(?:\.\d+)?$/.test(String(key)) && String(key).includes('.') && Number.isFinite(keyedPrice) && keyedPrice >= 0 && !Number.isFinite(Number(value.cost ?? value.price ?? value.Price))) {
|
||||
entries.push({ cost: keyedPrice, count: Number.isFinite(keyedCount) ? keyedCount : 0, inStock: !Number.isFinite(keyedCount) || keyedCount > 0 });
|
||||
}
|
||||
}
|
||||
collectPriceEntries(value, entries);
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
function filterPriceEntries(entries, settings) {
|
||||
const min = normalizePrice(settings.minPrice);
|
||||
const max = normalizePrice(settings.maxPrice);
|
||||
if (min !== null && max !== null && min > max) throw new Error(`价格区间无效:最低价 ${min} 高于最高价 ${max}。`);
|
||||
return entries.filter((e) => e.inStock && (min === null || e.cost >= min) && (max === null || e.cost <= max));
|
||||
}
|
||||
async function fetchPriceCandidates(settings) {
|
||||
const out = [];
|
||||
for (let index = 0; index < settings.countries.length; index += 1) {
|
||||
const country = settings.countries[index];
|
||||
try {
|
||||
const payload = await apiRequest(settings, { action: 'getPricesV3', service: settings.serviceCode, country: country.id }, `查询价格 ${country.label}`);
|
||||
const prices = filterPriceEntries(collectPriceEntries(payload), settings);
|
||||
prices.forEach((entry) => out.push({ country, countryIndex: index, price: entry.cost, count: entry.count }));
|
||||
} catch (error) {
|
||||
out.push({ country, countryIndex: index, price: Number.POSITIVE_INFINITY, count: 0, error: error.message || String(error) });
|
||||
}
|
||||
}
|
||||
return out
|
||||
.filter((x) => Number.isFinite(x.price))
|
||||
.sort((a, b) => (a.price - b.price) || (a.countryIndex - b.countryIndex));
|
||||
}
|
||||
async function releaseActivation(settings, activation, releaseAction = 'cancel', quiet = false) {
|
||||
if (!activation?.activationId) return { ok: false, skipped: true, releaseAction };
|
||||
const normalizedAction = String(releaseAction || '').trim().toLowerCase() === 'ban' ? 'ban' : 'cancel';
|
||||
try {
|
||||
// SMSBower uses the same lifecycle endpoint/status for cancel-style release here.
|
||||
const payload = await apiRequest(settings, { action: 'setStatus', status: 8, id: activation.activationId }, normalizedAction === 'ban' ? '释放被拒号码' : '取消上一个号码激活');
|
||||
return { ok: true, payload: describePayload(payload), releaseAction: normalizedAction, activationId: activation.activationId };
|
||||
} catch (error) {
|
||||
if (!quiet) throw error;
|
||||
return { ok: false, error: error.message || String(error), releaseAction: normalizedAction, activationId: activation.activationId };
|
||||
}
|
||||
}
|
||||
async function cancelActivation(settings, activation, quiet = false) {
|
||||
return releaseActivation(settings, activation, 'cancel', quiet);
|
||||
}
|
||||
async function banActivation(settings, activation, quiet = false) {
|
||||
return releaseActivation(settings, activation, 'ban', quiet);
|
||||
}
|
||||
async function requestActivation(settings, candidate) {
|
||||
const payload = await apiRequest(settings, {
|
||||
action: 'getNumberV2',
|
||||
service: settings.serviceCode,
|
||||
country: candidate.country.id,
|
||||
minPrice: normalizePrice(settings.minPrice),
|
||||
maxPrice: Number.isFinite(candidate.price) ? candidate.price : normalizePrice(settings.maxPrice),
|
||||
}, `购买手机号 ${candidate.country.label}`);
|
||||
const activation = normalizeActivation(payload, {
|
||||
countryId: candidate.country.id,
|
||||
countryLabel: candidate.country.label,
|
||||
serviceCode: settings.serviceCode,
|
||||
price: candidate.price,
|
||||
});
|
||||
if (!activation) throw new Error(`购买手机号返回不可用响应:${describePayload(payload)}`);
|
||||
return activation;
|
||||
}
|
||||
async function fetchNextPhone(options = {}) {
|
||||
const settings = await getSettings();
|
||||
const previous = settings.activeActivation;
|
||||
const reason = normalizeFailureReason(options.reason || 'manual_replace');
|
||||
const releaseAction = options.releaseAction || getPhoneReplacementReleaseAction(reason);
|
||||
const releasePrevious = previous ? await releaseActivation(settings, previous, releaseAction, true) : { ok: false, skipped: true, releaseAction };
|
||||
const candidates = await fetchPriceCandidates(settings);
|
||||
if (!candidates.length) throw new Error('当前国家和价格范围内没有可用库存。');
|
||||
const errors = [];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const activation = await requestActivation(settings, candidate);
|
||||
await saveSettings({ activeActivation: { ...activation, replacedReason: reason, previousActivationId: previous?.activationId || '' } });
|
||||
return { activation, releasePrevious, cancelPrevious: releasePrevious, tried: candidates.length, reason, releaseAction };
|
||||
} catch (error) {
|
||||
errors.push(`${candidate.country.label}: ${error.message || error}`);
|
||||
}
|
||||
}
|
||||
throw new Error(`获取手机号失败:${errors.join(' | ')}`);
|
||||
}
|
||||
function extractVerificationCode(raw = '') {
|
||||
const text = describePayload(raw).replace(/^STATUS_OK\s*:\s*/i, '').trim();
|
||||
const matched = text.match(/\b(\d{4,8})\b/);
|
||||
return matched?.[1] || text;
|
||||
}
|
||||
async function fetchActiveCode() {
|
||||
const settings = await getSettings();
|
||||
const activation = settings.activeActivation;
|
||||
if (!activation?.activationId) throw new Error('当前没有激活号码,请先获取手机号。');
|
||||
const payload = await apiRequest(settings, { action: 'getStatus', id: activation.activationId }, '查询当前验证码');
|
||||
const message = describePayload(payload);
|
||||
if (/^STATUS_OK\s*:/i.test(message)) {
|
||||
const code = extractVerificationCode(message);
|
||||
await saveSettings({ activeActivation: { ...activation, lastCode: code, lastCodeRaw: message, lastCodeAt: Date.now() } });
|
||||
return { code, raw: message, activationId: activation.activationId, phoneNumber: activation.phoneNumber };
|
||||
}
|
||||
if (/^STATUS_WAIT_CODE\b/i.test(message)) throw new Error('验证码还没到,稍后再点“复制验证码”。');
|
||||
if (/^STATUS_CANCEL\b/i.test(message)) throw new Error('当前激活已取消。');
|
||||
throw new Error(`暂未获取到验证码:${message || '空响应'}`);
|
||||
}
|
||||
async function fetchCountries(settings) {
|
||||
try {
|
||||
const payload = await apiRequest(settings, { action: 'getCountries' }, '获取国家列表');
|
||||
const list = [];
|
||||
if (Array.isArray(payload)) {
|
||||
payload.forEach((x) => list.push({ id: normalizeCountryId(x.id ?? x.country ?? x.code, -1), label: asText(x.name ?? x.label ?? x.title, '') }));
|
||||
} else if (payload && typeof payload === 'object') {
|
||||
Object.entries(payload).forEach(([id, value]) => {
|
||||
if (value && typeof value === 'object') list.push({ id: normalizeCountryId(value.id ?? id, -1), label: asText(value.name ?? value.label ?? value.title, `Country #${id}`) });
|
||||
else list.push({ id: normalizeCountryId(id, -1), label: asText(value, `Country #${id}`) });
|
||||
});
|
||||
}
|
||||
const seen = new Set();
|
||||
const normalized = list.filter((x) => x.id >= 0 && x.label && !seen.has(x.id) && seen.add(x.id)).sort((a, b) => a.id - b.id);
|
||||
return normalized.length ? normalized : FALLBACK_COUNTRIES.slice();
|
||||
} catch {
|
||||
return FALLBACK_COUNTRIES.slice();
|
||||
}
|
||||
}
|
||||
|
||||
chrome.action.onClicked.addListener(async (tab) => {
|
||||
if (!tab?.id) return;
|
||||
try {
|
||||
await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['content.js'] });
|
||||
await chrome.tabs.sendMessage(tab.id, { type: 'TOGGLE_FLOAT_PANEL' });
|
||||
} catch (error) {
|
||||
console.warn('SMSBower 悬浮窗打开失败:', error);
|
||||
}
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
(async () => {
|
||||
const type = String(message?.type || '');
|
||||
if (type === 'GET_SETTINGS') return { ok: true, settings: await getSettings(), fallbackCountries: FALLBACK_COUNTRIES };
|
||||
if (type === 'SAVE_SETTINGS') return { ok: true, settings: await saveSettings(message.payload || {}) };
|
||||
if (type === 'FETCH_COUNTRIES') return { ok: true, countries: await fetchCountries(await getSettings()) };
|
||||
if (type === 'FETCH_NEXT_PHONE') return { ok: true, ...(await fetchNextPhone(message.payload || {})) };
|
||||
if (type === 'ROTATE_PHONE') return { ok: true, ...(await fetchNextPhone(message.payload || {})) };
|
||||
if (type === 'CLASSIFY_PHONE_ERROR') {
|
||||
const text = String(message.payload?.text || '');
|
||||
const reason = isPhoneNumberUsedError(text) ? 'phone_number_used' : (isPhoneNumberDeliveryRefusedError(text) ? 'phone_delivery_refused' : 'unknown');
|
||||
return { ok: true, matched: reason !== 'unknown', reason, releaseAction: getPhoneReplacementReleaseAction(reason || text) };
|
||||
}
|
||||
if (type === 'FETCH_ACTIVE_CODE') return { ok: true, ...(await fetchActiveCode()) };
|
||||
if (type === 'CANCEL_ACTIVE') {
|
||||
const settings = await getSettings();
|
||||
const result = await cancelActivation(settings, settings.activeActivation, false);
|
||||
await saveSettings({ activeActivation: null });
|
||||
return { ok: true, result };
|
||||
}
|
||||
throw new Error(`未知消息:${type}`);
|
||||
})().then(sendResponse).catch((error) => sendResponse({ ok: false, error: error.message || String(error) }));
|
||||
return true;
|
||||
});
|
||||
Reference in New Issue
Block a user