feat: add Luban SMS provider

This commit is contained in:
chick
2026-05-31 19:13:45 +08:00
parent 2b127d070b
commit 09ccc76e71
5 changed files with 239 additions and 90 deletions
+105 -68
View File
@@ -1,7 +1,10 @@
const DEFAULT_SETTINGS = Object.freeze({
provider: 'sms-bower',
apiKey: '',
baseUrl: 'https://smsbower.app/stubs/handler_api.php',
lubanBaseUrl: 'https://lubansms.com/v2/api',
serviceCode: 'dr',
lubanServiceId: '',
minPrice: '',
maxPrice: '',
countries: [{ id: 6, label: 'Indonesia' }],
@@ -18,7 +21,18 @@ const FALLBACK_COUNTRIES = Object.freeze([
{ id: 151, label: 'Japan' }, { id: 187, label: 'USA' }
]);
const PROVIDERS = Object.freeze({
'sms-bower': {
id: 'sms-bower', name: 'SMSBower', apiKeyParam: 'api_key', baseUrlKey: 'baseUrl', defaultBaseUrl: 'https://smsbower.app/stubs/handler_api.php',
},
'luban-sms': {
id: 'luban-sms', name: 'Luban SMS', apiKeyParam: 'apikey', baseUrlKey: 'lubanBaseUrl', defaultBaseUrl: 'https://lubansms.com/v2/api',
},
});
function asText(value = '', fallback = '') { return String(value ?? '').trim() || fallback; }
function normalizeProvider(value = '') { return PROVIDERS[value] ? value : DEFAULT_SETTINGS.provider; }
function getProvider(settings = {}) { return PROVIDERS[normalizeProvider(settings.provider)] || PROVIDERS[DEFAULT_SETTINGS.provider]; }
function normalizeCountryId(value, fallback = 6) {
const parsed = Math.floor(Number(value));
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
@@ -43,21 +57,23 @@ function isPhoneNumberDeliveryRefusedError(value) {
}
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';
}
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;
const provider = normalizeProvider(input.provider);
return {
...DEFAULT_SETTINGS,
...input,
provider,
apiKey: asText(input.apiKey),
baseUrl: asText(input.baseUrl, DEFAULT_SETTINGS.baseUrl),
lubanBaseUrl: asText(input.lubanBaseUrl, DEFAULT_SETTINGS.lubanBaseUrl),
serviceCode: asText(input.serviceCode, DEFAULT_SETTINGS.serviceCode).toLowerCase().replace(/[^a-z0-9_-]+/g, '') || DEFAULT_SETTINGS.serviceCode,
lubanServiceId: asText(input.lubanServiceId ?? input.serviceId ?? input.luban_service_id, DEFAULT_SETTINGS.lubanServiceId).replace(/[^0-9a-zA-Z_-]+/g, ''),
minPrice: normalizePrice(input.minPrice) === null ? '' : String(normalizePrice(input.minPrice)),
maxPrice: normalizePrice(input.maxPrice) === null ? '' : String(normalizePrice(input.maxPrice)),
countries,
@@ -73,20 +89,31 @@ async function saveSettings(update = {}) {
await chrome.storage.local.set(next);
return next;
}
function providerBaseUrl(settings) {
const provider = getProvider(settings);
return asText(settings[provider.baseUrlKey], provider.defaultBaseUrl);
}
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));
});
const provider = getProvider(settings);
let base = providerBaseUrl(settings);
const queryEntries = { ...query };
if (provider.id === 'luban-sms') {
const endpoint = String(queryEntries.__endpoint || '').replace(/^\/+/, '');
delete queryEntries.__endpoint;
if (endpoint) {
const root = base.replace(/\/+(getBalance|getNumber|getSms|setStatus|getAgainNmuber|countries|List|smsHistory|getKeywordNumber|getKeywordSms|delKeywordNumber|keywordSmsHistory|bulksms)\/?$/i, '');
base = `${root.replace(/\/+$/, '')}/${endpoint}`;
}
}
const url = new URL(base);
if (settings.apiKey) url.searchParams.set(provider.apiKeyParam, settings.apiKey);
Object.entries(queryEntries).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 {}
}
if ((t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'))) { try { return JSON.parse(t); } catch {} }
return t;
}
function describePayload(payload) {
@@ -98,13 +125,15 @@ function describePayload(payload) {
}
return String(payload || '').trim();
}
async function apiRequest(settings, query, label = 'SMSBower 请求') {
if (!settings.apiKey) throw new Error('请先填写 SMSBower API Key。');
async function apiRequest(settings, query, label = '接码平台请求') {
const provider = getProvider(settings);
if (!settings.apiKey) throw new Error(`请先填写 ${provider.name} 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)) {
const code = payload && typeof payload === 'object' && !Array.isArray(payload) ? Number(payload.code) : null;
if (!response.ok || apiStatus === 0 || (Number.isFinite(code) && code !== 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;
@@ -115,18 +144,16 @@ function normalizeActivation(payload, fallback = {}) {
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 activationId = asText(src.activationId ?? src.activation_id ?? src.request_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),
activationId, phoneNumber, provider: fallback.provider || 'sms-bower',
serviceCode: asText(src.serviceCode ?? src.service_id ?? 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,
countryLabel: asText(src.countryLabel ?? src.country_name_zh ?? src.country_name_en ?? fallback.countryLabel, fallback.countryLabel || `Country #${countryId}`),
price: Number.isFinite(Number(src.price ?? src.cost ?? fallback.price)) ? Number(src.price ?? src.cost ?? fallback.price) : null,
acquiredAt: Date.now(),
};
}
@@ -154,7 +181,7 @@ function filterPriceEntries(entries, settings) {
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) {
async function fetchSmsBowerCandidates(settings) {
const out = [];
for (let index = 0; index < settings.countries.length; index += 1) {
const country = settings.countries[index];
@@ -162,46 +189,52 @@ async function fetchPriceCandidates(settings) {
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) });
}
} 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));
return out.filter((x) => Number.isFinite(x.price)).sort((a, b) => (a.price - b.price) || (a.countryIndex - b.countryIndex));
}
async function fetchLubanCandidates(settings) {
if (!settings.lubanServiceId) throw new Error('请先填写 Luban SMS service_id。可在 Luban 控制台或服务列表接口查询。');
let price = null;
try {
const payload = await apiRequest(settings, { __endpoint: 'List', country: '', service: '', language: 'en', page: 1 }, '查询 Luban 服务列表');
const list = Array.isArray(payload?.msg) ? payload.msg : [];
const found = list.find((x) => String(x.service_id) === String(settings.lubanServiceId));
if (found && Number.isFinite(Number(found.cost))) price = Number(found.cost);
} catch {}
if (normalizePrice(settings.minPrice) !== null && price !== null && price < normalizePrice(settings.minPrice)) return [];
if (normalizePrice(settings.maxPrice) !== null && price !== null && price > normalizePrice(settings.maxPrice)) return [];
return [{ country: { id: -1, label: 'Luban service' }, countryIndex: 0, price: price ?? 0, count: 1, serviceId: settings.lubanServiceId }];
}
async function fetchPriceCandidates(settings) {
return getProvider(settings).id === 'luban-sms' ? fetchLubanCandidates(settings) : fetchSmsBowerCandidates(settings);
}
async function releaseActivation(settings, activation, releaseAction = 'cancel', quiet = false) {
if (!activation?.activationId) return { ok: false, skipped: true, releaseAction };
const provider = getProvider(settings);
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' ? '释放被拒号码' : '取消上一个号码激活');
let payload;
if (provider.id === 'luban-sms') payload = await apiRequest(settings, { __endpoint: 'setStatus', request_id: activation.activationId, status: 'reject' }, '释放 Luban 号码');
else 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 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,
});
const provider = getProvider(settings);
if (provider.id === 'luban-sms') {
const payload = await apiRequest(settings, { __endpoint: 'getNumber', service_id: candidate.serviceId || settings.lubanServiceId }, '购买 Luban 手机号');
const activation = normalizeActivation(payload, { provider: provider.id, serviceCode: settings.lubanServiceId, price: candidate.price, countryId: -1, countryLabel: 'Luban service' });
if (!activation) throw new Error(`购买手机号返回不可用响应:${describePayload(payload)}`);
return activation;
}
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, { provider: provider.id, countryId: candidate.country.id, countryLabel: candidate.country.label, serviceCode: settings.serviceCode, price: candidate.price });
if (!activation) throw new Error(`购买手机号返回不可用响应:${describePayload(payload)}`);
return activation;
}
@@ -212,16 +245,14 @@ async function fetchNextPhone(options = {}) {
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('当前国家和价格范围内没有可用库存。');
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}`);
}
} catch (error) { errors.push(`${candidate.country.label}: ${error.message || error}`); }
}
throw new Error(`获取手机号失败:${errors.join(' | ')}`);
}
@@ -234,8 +265,20 @@ 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 provider = getProvider(settings);
const payload = provider.id === 'luban-sms'
? await apiRequest(settings, { __endpoint: 'getSms', request_id: activation.activationId }, '查询 Luban 验证码')
: await apiRequest(settings, { action: 'getStatus', id: activation.activationId }, '查询当前验证码');
const message = describePayload(payload);
if (provider.id === 'luban-sms') {
if (payload?.msg === 'success' && payload.sms_code) {
const code = extractVerificationCode(payload.sms_code);
await saveSettings({ activeActivation: { ...activation, lastCode: code, lastCodeRaw: message, lastCodeAt: Date.now() } });
return { code, raw: message, activationId: activation.activationId, phoneNumber: activation.phoneNumber };
}
if (payload?.msg === 'wait') throw new Error('验证码还没到,稍后再点“复制验证码”。');
throw new Error(`暂未获取到验证码:${message || '空响应'}`);
}
if (/^STATUS_OK\s*:/i.test(message)) {
const code = extractVerificationCode(message);
await saveSettings({ activeActivation: { ...activation, lastCode: code, lastCodeRaw: message, lastCodeAt: Date.now() } });
@@ -246,23 +289,19 @@ async function fetchActiveCode() {
throw new Error(`暂未获取到验证码:${message || '空响应'}`);
}
async function fetchCountries(settings) {
if (getProvider(settings).id === 'luban-sms') return [{ id: -1, label: 'Luban service_id 模式' }];
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}`) });
});
}
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();
}
} catch { return FALLBACK_COUNTRIES.slice(); }
}
chrome.action.onClicked.addListener(async (tab) => {
@@ -270,9 +309,7 @@ chrome.action.onClicked.addListener(async (tab) => {
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);
}
} catch (error) { console.warn('SMSBower 悬浮窗打开失败:', error); }
});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {