From a9c7cf4ae170a485411f1342b7478a5b74c8f842 Mon Sep 17 00:00:00 2001 From: chick Date: Sun, 31 May 2026 16:05:42 +0800 Subject: [PATCH] feat: add smsbower phone fill tool with rotation --- README.md | 35 ++++ background.js | 301 ++++++++++++++++++++++++++ content.js | 465 +++++++++++++++++++++++++++++++++++++++++ manifest.json | 10 + popup.css | 21 ++ popup.html | 42 ++++ popup.js | 147 +++++++++++++ tests/provider.test.js | 99 +++++++++ 8 files changed, 1120 insertions(+) create mode 100644 README.md create mode 100644 background.js create mode 100644 content.js create mode 100644 manifest.json create mode 100644 popup.css create mode 100644 popup.html create mode 100644 popup.js create mode 100644 tests/provider.test.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..705663f --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +SMSBower Phone Fill Tool + +用途:点击扩展图标后,在当前网页右上角打开一个常驻悬浮窗。点一次“获取并填入”就获取一个 SMSBower 手机号,自动填入当前页面最像手机号的输入框,并复制到剪贴板。再次获取会先释放上一个激活,再拿新号替换页面输入框。 + +本版已集成 FlowPilot 同款切号机制: +- 页面提示“该电话/手机号已被使用、已绑定、已注册”时,可点“检测报错换号”自动识别,或直接点“已使用换号”;旧号按被拒原因释放,新号自动填入并复制。 +- 页面提示“无法向此电话号码发送验证码”等发送失败时,“检测报错换号”会按可恢复失败换号。 +- 仍然只负责填号/查码/复制,不自动点击目标页面继续按钮。 + +安装/更新: +1. 打开 Chrome/Edge 扩展管理页。 +2. 开启“开发者模式”。 +3. 加载已解压的扩展,选择本目录。 +4. 如果之前已经加载过,点扩展卡片上的“重新加载”。 + +使用: +1. 打开任意网页,先点一下你要填写的输入框。 +2. 点浏览器工具栏里的“SMSBower 悬浮填号”扩展图标。 +3. 页面右上角会出现悬浮窗,可拖动。 +4. 填 SMSBower API Key、项目代码、价格上下限、国家顺序。 +5. 顶部“获取并填入”会立即拿号并填入。 +6. 再点一次“获取并填入”会释放上一个激活,替换输入框为新手机号。 +7. 如果目标页面拒绝号码: + - 先点“检测报错换号”,插件会扫描当前页面文本,识别“已使用/发送验证码失败”等错误并换号; + - 如果你已经确认是“号码已使用”,可直接点“已使用换号”。 +8. 点“获取验证码”后会每 1 秒查询一次当前激活的验证码状态;如果 SMSBower 已收到 STATUS_OK,会显示验证码并停止。点获取新号、保存、刷新国家或取消当前会停止这轮验证码轮询。 +9. 看到验证码后,点“复制验证码”会复制当前显示的验证码。 +10. 国家搜索支持中文别名,例如搜“印度”会出现 India 和 Indonesia。 +11. 点悬浮窗右上角 × 只是隐藏;再次点扩展图标会重新显示。 + +说明: +- “复制验证码”只查询当前 SMSBower 激活订单的 getStatus,不会自动填验证码。 +- 拿号优先级:价格低优先;同价格按你选择国家的顺序优先。 +- SMSBower 默认接口地址为 https://smsbower.app/stubs/handler_api.php,可在悬浮窗里改。 +- 本地验证:`node --test tests/provider.test.js`。 diff --git a/background.js b/background.js new file mode 100644 index 0000000..5b29671 --- /dev/null +++ b/background.js @@ -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; +}); diff --git a/content.js b/content.js new file mode 100644 index 0000000..c3083df --- /dev/null +++ b/content.js @@ -0,0 +1,465 @@ +(function smsBowerFloatingTool() { + if (window.__smsBowerFloatingToolInstalled) { + return; + } + window.__smsBowerFloatingToolInstalled = true; + + const PANEL_ID = 'smsbower-floating-phone-tool'; + const FALLBACK_COUNTRIES = [ + { 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' } + ]; + const COUNTRY_ALIASES = { + 0: '俄罗斯 俄国 Russia RU', 1: '乌克兰 Ukraine UA', 2: '哈萨克斯坦 Kazakhstan KZ', + 3: '中国 China CN', 4: '菲律宾 Philippines PH', 5: '缅甸 Myanmar Burma MM', + 6: '印度尼西亚 印尼 Indonesia ID', 7: '马来西亚 Malaysia MY', 10: '越南 Vietnam VN', + 12: '美国虚拟 USA virtual United States US', 16: '英国 United Kingdom Britain GB UK', + 22: '印度 India IN', 32: '罗马尼亚 Romania RO', 36: '加拿大 Canada CA', + 43: '德国 Germany DE', 52: '泰国 Thailand TH', 73: '法国 France FR', + 78: '土耳其 Turkey TR', 151: '日本 Japan JP', 187: '美国 USA United States US' + }; + let allCountries = FALLBACK_COUNTRIES.slice(); + let selectedCountries = [{ id: 6, label: 'Indonesia' }]; + let latestSettings = null; + let panel = null; + let dragState = null; + let codePollToken = 0; + + function stopCodePolling() { + codePollToken += 1; + } + + function isVisible(el) { + if (!el) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0; + } + + function scoreInput(el) { + const text = [el.type, el.name, el.id, el.placeholder, el.autocomplete, el.getAttribute('aria-label')].join(' ').toLowerCase(); + let score = 0; + if (el === document.activeElement) score += 100; + if (/phone|mobile|tel|手机号|電話|电话|号码|number/.test(text)) score += 50; + if (el.type === 'tel') score += 40; + if (el.type === 'text' || !el.type) score += 5; + if (el.readOnly || el.disabled) score -= 1000; + if (!isVisible(el)) score -= 1000; + return score; + } + + function findBestInput() { + const inputs = Array.from(document.querySelectorAll('input, textarea')) + .filter((el) => !panel || !panel.contains(el)); + const ranked = inputs.map((el) => ({ el, score: scoreInput(el) })).sort((a, b) => b.score - a.score); + return ranked[0]?.score > -100 ? ranked[0].el : null; + } + + function setValue(el, value) { + el.focus(); + const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + const desc = Object.getOwnPropertyDescriptor(proto, 'value'); + if (desc?.set) desc.set.call(el, value); else el.value = value; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + } + + function fillPhone(phoneNumber) { + const input = findBestInput(); + if (!input) throw new Error('当前页面没有找到可填写的输入框。请先点一下目标输入框再获取。'); + setValue(input, phoneNumber); + return { phoneNumber, tag: input.tagName, name: input.name || '', id: input.id || '' }; + } + + function send(type, payload = {}) { + return chrome.runtime.sendMessage({ type, payload }).then((res) => { + if (!res?.ok) throw new Error(res?.error || '操作失败'); + return res; + }); + } + + function normalizeCountry(country) { + const id = Math.floor(Number(country?.id)); + return { id, label: String(country?.label || `Country #${id}`).trim() }; + } + + function sameCountry(a, b) { + return Number(a?.id) === Number(b?.id); + } + + function getCountrySearchText(country = {}) { + const id = Math.floor(Number(country?.id)); + return [country?.label, id, COUNTRY_ALIASES[id] || ''].join(' ').toLowerCase(); + } + + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + function $(selector) { + return panel?.querySelector(selector) || null; + } + + function setStatus(text, isError = false) { + const node = $('.sb-status'); + if (!node) return; + node.textContent = text; + node.style.color = isError ? '#fecaca' : '#bbf7d0'; + } + + async function copyText(text) { + try { + await navigator.clipboard.writeText(text); + } catch {} + } + + function collectSettings() { + return { + apiKey: $('.sb-api-key')?.value.trim() || '', + baseUrl: $('.sb-base-url')?.value.trim() || '', + serviceCode: $('.sb-service-code')?.value.trim() || 'dr', + minPrice: $('.sb-min-price')?.value.trim() || '', + maxPrice: $('.sb-max-price')?.value.trim() || '', + countries: selectedCountries, + }; + } + + async function saveSettings() { + stopCodePolling(); + const res = await send('SAVE_SETTINGS', collectSettings()); + latestSettings = res.settings; + selectedCountries = (res.settings.countries || []).map(normalizeCountry); + renderCountries(); + setStatus('设置已保存'); + } + + async function refreshCountries() { + stopCodePolling(); + await saveSettings(); + setStatus('正在刷新国家列表...'); + const res = await send('FETCH_COUNTRIES'); + allCountries = (res.countries || []).map(normalizeCountry).filter((c) => c.id >= 0); + renderCountries(); + setStatus('国家列表已刷新'); + } + + async function fetchAndFill(options = {}) { + stopCodePolling(); + const btn = $('.sb-fetch'); + if (btn) btn.disabled = true; + try { + await saveSettings(); + const reason = options.reason || 'manual_replace'; + const releaseAction = options.releaseAction || (reason === 'phone_number_used' ? 'ban' : 'cancel'); + setStatus(releaseAction === 'ban' ? '正在释放被拒号码并获取新号...' : '正在取消上一个激活并获取新号...'); + const res = await send('FETCH_NEXT_PHONE', { reason, releaseAction }); + const phone = res.activation.phoneNumber; + await copyText(phone); + fillPhone(phone); + $('.sb-current-phone').textContent = phone; + $('.sb-current-id').textContent = res.activation.activationId; + const codeNode = $('.sb-current-code'); + if (codeNode) codeNode.textContent = '无'; + setStatus(`已切换、填入并复制:${phone}`); + } catch (error) { + setStatus(error.message || String(error), true); + } finally { + if (btn) btn.disabled = false; + } + } + + function getPageTextForPhoneError() { + const clone = document.body?.cloneNode(true); + if (!clone) return ''; + clone.querySelector(`#${PANEL_ID}`)?.remove(); + return String(clone.innerText || clone.textContent || '').slice(-12000); + } + + async function detectAndRotate() { + const text = getPageTextForPhoneError(); + const classified = await send('CLASSIFY_PHONE_ERROR', { text }); + if (!classified.matched) { + setStatus('未检测到明确的手机号错误,可用“手动换号”。', true); + return; + } + await fetchAndFill({ reason: classified.reason, releaseAction: classified.releaseAction }); + } + + async function cancelActive() { + stopCodePolling(); + try { + setStatus('正在取消当前激活...'); + await send('CANCEL_ACTIVE'); + $('.sb-current-phone').textContent = '无'; + $('.sb-current-id').textContent = '无'; + const codeNode = $('.sb-current-code'); + if (codeNode) codeNode.textContent = '无'; + setStatus('当前激活已取消'); + } catch (error) { + setStatus(error.message || String(error), true); + } + } + + async function fetchAndCopyCode() { + stopCodePolling(); + const token = codePollToken; + const btn = $('.sb-code'); + if (btn) { + btn.disabled = true; + btn.textContent = '获取中...'; + } + try { + let round = 0; + while (token === codePollToken) { + round += 1; + setStatus(`正在查询验证码,第 ${round} 次...`); + try { + const res = await send('FETCH_ACTIVE_CODE'); + if (token !== codePollToken) return; + const codeNode = $('.sb-current-code'); + if (codeNode) codeNode.textContent = res.code || '无'; + setStatus(`已获取验证码:${res.code}`); + return; + } catch (error) { + if (token !== codePollToken) return; + const message = error.message || String(error); + if (/未知消息\s*[::]\s*FETCH_ACTIVE_CODE/i.test(message)) { + throw new Error('后台脚本还是旧版,没加载验证码接口。请到扩展管理页点“重新加载”,然后刷新当前网页并重新打开悬浮窗。'); + } + if (/验证码还没到|STATUS_WAIT_CODE|NO_CODE|WAIT/i.test(message)) { + setStatus(`验证码还没到,1 秒后继续查询...(第 ${round} 次)`); + await sleep(1000); + continue; + } + throw error; + } + } + } catch (error) { + if (token === codePollToken) setStatus(error.message || String(error), true); + } finally { + if (token === codePollToken && btn) { + btn.disabled = false; + btn.textContent = '获取验证码'; + } + } + } + + function renderCountries() { + const selectedBox = $('.sb-selected-countries'); + const list = $('.sb-country-list'); + const keyword = ($('.sb-country-search')?.value || '').trim().toLowerCase(); + if (!selectedBox || !list) return; + selectedBox.innerHTML = ''; + selectedCountries.forEach((country, index) => { + const chip = document.createElement('span'); + chip.className = 'sb-chip'; + chip.textContent = `${index + 1}. ${country.label} #${country.id}`; + const close = document.createElement('button'); + close.type = 'button'; + close.textContent = '×'; + close.addEventListener('click', () => { + selectedCountries = selectedCountries.filter((x) => !sameCountry(x, country)); + renderCountries(); + }); + chip.appendChild(close); + selectedBox.appendChild(chip); + }); + list.innerHTML = ''; + allCountries + .filter((c) => !keyword || getCountrySearchText(c).includes(keyword)) + .slice(0, 120) + .forEach((country) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'sb-country-item' + (selectedCountries.some((x) => sameCountry(x, country)) ? ' selected' : ''); + btn.innerHTML = `${escapeHtml(country.label)}#${country.id}`; + btn.addEventListener('click', () => { + if (selectedCountries.some((x) => sameCountry(x, country))) { + selectedCountries = selectedCountries.filter((x) => !sameCountry(x, country)); + } else { + selectedCountries.push(country); + } + renderCountries(); + }); + list.appendChild(btn); + }); + } + + function escapeHtml(value) { + return String(value || '').replace(/[&<>'"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[ch])); + } + + function injectStyles() { + if (document.getElementById('smsbower-floating-phone-tool-style')) return; + const style = document.createElement('style'); + style.id = 'smsbower-floating-phone-tool-style'; + style.textContent = ` + #${PANEL_ID} { position: fixed; z-index: 2147483647; right: 18px; top: 80px; width: 390px; max-height: calc(100vh - 110px); overflow: auto; box-sizing: border-box; padding: 12px; border: 1px solid #334155; border-radius: 14px; background: #0f172a; color: #e5e7eb; box-shadow: 0 18px 60px rgba(0,0,0,.45); font: 13px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; } + #${PANEL_ID} * { box-sizing: border-box; } + #${PANEL_ID} .sb-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; cursor: move; user-select: none; } + #${PANEL_ID} .sb-title { font-size: 16px; font-weight: 700; } + #${PANEL_ID} .sb-close { width: 28px; height: 28px; padding: 0; border: 1px solid #334155; border-radius: 8px; color: #e5e7eb; background: #1f2937; cursor: pointer; } + #${PANEL_ID} label { display: grid; gap: 4px; margin: 7px 0; color: #cbd5e1; } + #${PANEL_ID} input { width: 100%; padding: 7px 9px; border: 1px solid #334155; border-radius: 8px; color: #e5e7eb; background: #111827; outline: none; } + #${PANEL_ID} input:focus { border-color: #38bdf8; box-shadow: 0 0 0 2px rgba(56,189,248,.18); } + #${PANEL_ID} .sb-row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; } + #${PANEL_ID} .sb-country-box { margin-top: 9px; padding: 9px; border: 1px solid #253244; border-radius: 10px; background: #111827; } + #${PANEL_ID} .sb-country-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 7px; } + #${PANEL_ID} button { padding: 7px 10px; border: 1px solid #334155; border-radius: 8px; color: #e5e7eb; background: #1f2937; cursor: pointer; font: inherit; } + #${PANEL_ID} button:hover { background: #273449; } + #${PANEL_ID} button:disabled { opacity: .55; cursor: not-allowed; } + #${PANEL_ID} .sb-primary { background: #2563eb; border-color: #3b82f6; } + #${PANEL_ID} .sb-primary:hover { background: #1d4ed8; } + #${PANEL_ID} .sb-selected-countries { display: flex; flex-wrap: wrap; gap: 6px; min-height: 24px; margin: 7px 0; } + #${PANEL_ID} .sb-chip { display: inline-flex; gap: 5px; align-items: center; padding: 4px 7px; border-radius: 999px; background: #0e7490; color: #ecfeff; } + #${PANEL_ID} .sb-chip button { padding: 0 3px; border: 0; background: transparent; } + #${PANEL_ID} .sb-country-list { max-height: 150px; overflow: auto; border: 1px solid #253244; border-radius: 8px; } + #${PANEL_ID} .sb-country-item { display: flex; justify-content: space-between; gap: 8px; width: 100%; padding: 7px 9px; border: 0; border-bottom: 1px solid #1f2937; border-radius: 0; background: #0b1220; text-align: left; } + #${PANEL_ID} .sb-country-item.selected { background: #123044; color: #bae6fd; } + #${PANEL_ID} .sb-top-fetch { width: 100%; margin: 2px 0 10px; font-weight: 700; } + #${PANEL_ID} .sb-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 11px; } + #${PANEL_ID} .sb-code-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; } + #${PANEL_ID} .sb-current { margin-top: 10px; padding: 9px; border-radius: 10px; background: #020617; color: #cbd5e1; } + #${PANEL_ID} code { color: #67e8f9; user-select: all; } + #${PANEL_ID} .sb-hint { margin: 7px 0 0; color: #94a3b8; font-size: 12px; } + #${PANEL_ID} .sb-status { color: #bbf7d0; } + `; + document.documentElement.appendChild(style); + } + + async function createPanel() { + injectStyles(); + panel = document.createElement('div'); + panel.id = PANEL_ID; + panel.innerHTML = ` +
+ SMSBower 填号 + +
+ + + +
+ + + +
+
+
国家顺序
+ +
+
+

优先级:价低优先;同价按国家选择顺序。

+
+
+ + + + +
+
+ + +
+
+
当前号码:
+
激活ID:
+
验证码:
+
状态:就绪
+
+ `; + document.documentElement.appendChild(panel); + bindPanelEvents(); + await loadSettingsIntoPanel(); + } + + function bindPanelEvents() { + $('.sb-close').addEventListener('click', () => { panel.style.display = 'none'; }); + $('.sb-save').addEventListener('click', saveSettings); + $('.sb-fetch').addEventListener('click', () => fetchAndFill({ reason: 'manual_replace', releaseAction: 'cancel' })); + $('.sb-rotate-detect').addEventListener('click', detectAndRotate); + $('.sb-rotate-used').addEventListener('click', () => fetchAndFill({ reason: 'phone_number_used', releaseAction: 'ban' })); + $('.sb-cancel').addEventListener('click', cancelActive); + $('.sb-code').addEventListener('click', fetchAndCopyCode); + $('.sb-copy-code').addEventListener('click', async () => { + const code = $('.sb-current-code')?.textContent || ''; + if (code && code !== '无') { + await copyText(code); + setStatus(`验证码已复制:${code}`); + } else { + setStatus('当前没有验证码可复制,请先点“获取验证码”。', true); + } + }); + $('.sb-refresh').addEventListener('click', refreshCountries); + $('.sb-country-search').addEventListener('input', renderCountries); + const head = $('.sb-head'); + head.addEventListener('mousedown', (event) => { + if (event.target.closest('button')) return; + const rect = panel.getBoundingClientRect(); + dragState = { x: event.clientX, y: event.clientY, left: rect.left, top: rect.top }; + event.preventDefault(); + }); + window.addEventListener('mousemove', (event) => { + if (!dragState) return; + panel.style.left = `${Math.max(0, dragState.left + event.clientX - dragState.x)}px`; + panel.style.top = `${Math.max(0, dragState.top + event.clientY - dragState.y)}px`; + panel.style.right = 'auto'; + }); + window.addEventListener('mouseup', () => { dragState = null; }); + } + + async function loadSettingsIntoPanel() { + const res = await send('GET_SETTINGS'); + latestSettings = res.settings; + allCountries = (res.fallbackCountries || FALLBACK_COUNTRIES).map(normalizeCountry); + selectedCountries = (latestSettings.countries || []).map(normalizeCountry); + $('.sb-api-key').value = latestSettings.apiKey || ''; + $('.sb-base-url').value = latestSettings.baseUrl || ''; + $('.sb-service-code').value = latestSettings.serviceCode || 'dr'; + $('.sb-min-price').value = latestSettings.minPrice || ''; + $('.sb-max-price').value = latestSettings.maxPrice || ''; + if (latestSettings.activeActivation) { + $('.sb-current-phone').textContent = latestSettings.activeActivation.phoneNumber || '无'; + $('.sb-current-id').textContent = latestSettings.activeActivation.activationId || '无'; + const codeNode = $('.sb-current-code'); + if (codeNode) codeNode.textContent = latestSettings.activeActivation.lastCode || '无'; + } + renderCountries(); + } + + async function togglePanel() { + try { + if (!panel || !document.documentElement.contains(panel)) { + await createPanel(); + } else { + panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; + if (panel.style.display !== 'none') await loadSettingsIntoPanel(); + } + } catch (error) { + alert(`SMSBower 悬浮窗打开失败:${error.message || error}`); + } + } + + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + (async () => { + if (message?.type === 'FILL_PHONE') { + const phoneNumber = String(message.phoneNumber || '').trim(); + if (!phoneNumber) throw new Error('缺少手机号。'); + return { ok: true, ...fillPhone(phoneNumber) }; + } + if (message?.type === 'TOGGLE_FLOAT_PANEL') { + await togglePanel(); + return { ok: true }; + } + return undefined; + })().then((result) => { + if (result !== undefined) sendResponse(result); + }).catch((error) => sendResponse({ ok: false, error: error.message || String(error) })); + return true; + }); +})(); diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..c934cd6 --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "manifest_version": 3, + "name": "SMSBower Phone Fill Tool", + "version": "1.4.0", + "description": "Fetch one SMSBower phone number, fill it into the current page input, copy it, and cancel the previous activation on replacement.", + "permissions": ["activeTab", "scripting", "storage", "clipboardWrite"], + "host_permissions": ["https://smsbower.app/*", "https://smsbower.page/*", "https://smsbower.online/*"], + "background": { "service_worker": "background.js" }, + "action": { "default_title": "SMSBower 悬浮填号" } +} diff --git a/popup.css b/popup.css new file mode 100644 index 0000000..9bec275 --- /dev/null +++ b/popup.css @@ -0,0 +1,21 @@ +body { width: 430px; margin: 0; font: 13px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #e5e7eb; background: #0f172a; } +main { padding: 14px; } h1 { margin: 0 0 12px; font-size: 18px; } +label { display: grid; gap: 5px; margin: 8px 0; color: #cbd5e1; } +input { box-sizing: border-box; width: 100%; padding: 7px 9px; border: 1px solid #334155; border-radius: 8px; color: #e5e7eb; background: #111827; outline: none; } +input:focus { border-color: #38bdf8; box-shadow: 0 0 0 2px rgba(56,189,248,.18); } +.row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; } +.country-box { margin-top: 10px; padding: 10px; border: 1px solid #253244; border-radius: 10px; background: #111827; } +.country-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; } +button { padding: 7px 10px; border: 1px solid #334155; border-radius: 8px; color: #e5e7eb; background: #1f2937; cursor: pointer; } +button:hover { background: #273449; } button:disabled { opacity: .55; cursor: not-allowed; } +.primary { background: #2563eb; border-color: #3b82f6; } .primary:hover { background: #1d4ed8; } +.selected { display: flex; flex-wrap: wrap; gap: 6px; min-height: 25px; margin: 8px 0; } +.chip { display: inline-flex; gap: 5px; align-items: center; padding: 4px 7px; border-radius: 999px; background: #0e7490; color: #ecfeff; } +.chip button { padding: 0 3px; border: 0; background: transparent; } +.country-list { max-height: 170px; overflow: auto; border: 1px solid #253244; border-radius: 8px; } +.country-item { display: flex; justify-content: space-between; gap: 8px; width: 100%; padding: 7px 9px; border: 0; border-bottom: 1px solid #1f2937; border-radius: 0; background: #0b1220; text-align: left; } +.country-item.selected-item { background: #123044; color: #bae6fd; } +.country-id { color: #94a3b8; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.actions { display: grid; grid-template-columns: 1fr 1.4fr 1fr; gap: 8px; margin-top: 12px; } +.current { margin-top: 12px; padding: 10px; border-radius: 10px; background: #020617; color: #cbd5e1; } +code { color: #67e8f9; user-select: all; } .hint { margin: 7px 0 0; color: #94a3b8; font-size: 12px; } diff --git a/popup.html b/popup.html new file mode 100644 index 0000000..9c95479 --- /dev/null +++ b/popup.html @@ -0,0 +1,42 @@ + + + + + + SMSBower 填号 + + + +
+

SMSBower 填号

+ + +
+ + + +
+
+
+ 国家顺序 + +
+ +
+
+

拿号优先级:价低优先;同价按上面选中的国家顺序优先。

+
+
+ + + +
+
+
当前号码:
+
激活ID:
+
状态:就绪
+
+
+ + + diff --git a/popup.js b/popup.js new file mode 100644 index 0000000..64b88e9 --- /dev/null +++ b/popup.js @@ -0,0 +1,147 @@ +let allCountries = []; +let selectedCountries = []; +const $ = (id) => document.getElementById(id); +function send(type, payload = {}) { + return chrome.runtime.sendMessage({ type, payload }).then((res) => { + if (!res?.ok) throw new Error(res?.error || '操作失败'); + return res; + }); +} +function setStatus(text, isError = false) { + $('status').textContent = text; + $('status').style.color = isError ? '#fca5a5' : '#a7f3d0'; +} +function normalizeCountry(country) { + const id = Math.floor(Number(country?.id)); + return { id, label: String(country?.label || `Country #${id}`).trim() }; +} +function sameCountry(a, b) { return Number(a?.id) === Number(b?.id); } +function renderSelected() { + const box = $('selected-countries'); + box.innerHTML = ''; + selectedCountries.forEach((country, index) => { + const chip = document.createElement('span'); + chip.className = 'chip'; + chip.textContent = `${index + 1}. ${country.label} #${country.id}`; + const close = document.createElement('button'); + close.type = 'button'; + close.textContent = '×'; + close.addEventListener('click', () => { selectedCountries = selectedCountries.filter((x) => !sameCountry(x, country)); renderAll(); }); + chip.appendChild(close); + box.appendChild(chip); + }); +} +function renderCountryList() { + const keyword = $('country-search').value.trim().toLowerCase(); + const list = $('country-list'); + list.innerHTML = ''; + allCountries + .filter((c) => !keyword || String(c.id).includes(keyword) || c.label.toLowerCase().includes(keyword)) + .slice(0, 120) + .forEach((country) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'country-item' + (selectedCountries.some((x) => sameCountry(x, country)) ? ' selected-item' : ''); + btn.innerHTML = `${country.label}#${country.id}`; + btn.addEventListener('click', () => { + if (selectedCountries.some((x) => sameCountry(x, country))) { + selectedCountries = selectedCountries.filter((x) => !sameCountry(x, country)); + } else { + selectedCountries.push(country); + } + renderAll(); + }); + list.appendChild(btn); + }); +} +function renderAll() { renderSelected(); renderCountryList(); } +function collectSettings() { + return { + apiKey: $('api-key').value.trim(), + baseUrl: $('base-url').value.trim(), + serviceCode: $('service-code').value.trim(), + minPrice: $('min-price').value.trim(), + maxPrice: $('max-price').value.trim(), + countries: selectedCountries, + }; +} +async function saveSettings() { + const res = await send('SAVE_SETTINGS', collectSettings()); + selectedCountries = res.settings.countries.map(normalizeCountry); + renderAll(); + setStatus('设置已保存'); +} +async function fillCurrentTab(phoneNumber) { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab?.id) throw new Error('没有找到当前标签页。'); + try { + await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['content.js'] }); + } catch {} + const res = await chrome.tabs.sendMessage(tab.id, { type: 'FILL_PHONE', phoneNumber }); + if (!res?.ok) throw new Error(res?.error || '填入页面失败。'); + return res; +} +async function copyText(text) { + try { await navigator.clipboard.writeText(text); } catch {} +} +async function fetchAndFill(options = {}) { + $('fetch-fill').disabled = true; + try { + await saveSettings(); + const releaseAction = options.releaseAction || 'cancel'; + setStatus(releaseAction === 'ban' ? '正在释放被拒号码并按低价拿号...' : '正在取消上一个激活并按低价拿号...'); + const res = await send('FETCH_NEXT_PHONE', { reason: options.reason || 'manual_replace', releaseAction }); + const phone = res.activation.phoneNumber; + await copyText(phone); + setStatus('正在填入当前页面输入框...'); + await fillCurrentTab(phone); + $('current-phone').textContent = phone; + $('current-id').textContent = res.activation.activationId; + setStatus(`已获取、已复制、已填入:${phone}`); + } catch (error) { + setStatus(error.message || String(error), true); + } finally { + $('fetch-fill').disabled = false; + } +} +async function cancelActive() { + try { + setStatus('正在取消当前激活...'); + await send('CANCEL_ACTIVE'); + $('current-phone').textContent = '无'; + $('current-id').textContent = '无'; + setStatus('当前激活已取消'); + } catch (error) { setStatus(error.message || String(error), true); } +} +async function refreshCountries() { + try { + await saveSettings(); + setStatus('正在刷新国家列表...'); + const res = await send('FETCH_COUNTRIES'); + allCountries = res.countries.map(normalizeCountry).filter((c) => c.id >= 0); + renderAll(); + setStatus('国家列表已刷新'); + } catch (error) { setStatus(error.message || String(error), true); } +} +async function init() { + const res = await send('GET_SETTINGS'); + const settings = res.settings; + $('api-key').value = settings.apiKey || ''; + $('base-url').value = settings.baseUrl || ''; + $('service-code').value = settings.serviceCode || 'dr'; + $('min-price').value = settings.minPrice || ''; + $('max-price').value = settings.maxPrice || ''; + selectedCountries = (settings.countries || []).map(normalizeCountry); + allCountries = (res.fallbackCountries || []).map(normalizeCountry); + if (settings.activeActivation) { + $('current-phone').textContent = settings.activeActivation.phoneNumber || '无'; + $('current-id').textContent = settings.activeActivation.activationId || '无'; + } + $('save').addEventListener('click', saveSettings); + $('fetch-fill').addEventListener('click', () => fetchAndFill({ reason: 'manual_replace', releaseAction: 'cancel' })); + $('cancel-active').addEventListener('click', cancelActive); + $('refresh-countries').addEventListener('click', refreshCountries); + $('country-search').addEventListener('input', renderCountryList); + renderAll(); +} +init().catch((error) => setStatus(error.message || String(error), true)); diff --git a/tests/provider.test.js b/tests/provider.test.js new file mode 100644 index 0000000..4724863 --- /dev/null +++ b/tests/provider.test.js @@ -0,0 +1,99 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const source = fs.readFileSync('background.js', 'utf8'); +function load(extra = {}) { + const sandbox = { + chrome: { + action: { onClicked: { addListener() {} } }, + tabs: { async sendMessage() {} }, + scripting: { async executeScript() {} }, + runtime: { onMessage: { addListener() {} } }, + storage: { local: { async get() { return {}; }, async set() {} } } + }, + fetch: async () => { throw new Error('not mocked'); }, + URL, + console, + Date, + Number, + String, + Object, + Array, + RegExp, + Math, + JSON, + Set, + ...extra, + }; + const fn = new Function('globalThis', 'self', `with (globalThis) { ${source}; return { collectPriceEntries, filterPriceEntries, normalizeActivation, extractVerificationCode, isPhoneNumberUsedError, isPhoneNumberDeliveryRefusedError, getPhoneReplacementReleaseAction, fetchNextPhone, normalizeSettings }; }`); + return fn(sandbox, sandbox); +} +test('collectPriceEntries parses keyed price inventory', () => { + const api = load(); + const entries = api.collectPriceEntries({ 6: { dr: { '0.12': { count: 3 }, '0.20': { count: 0 } } } }); + assert.deepEqual(entries.filter((x) => x.inStock).map((x) => x.cost), [0.12]); +}); +test('filterPriceEntries respects min and max', () => { + const api = load(); + const entries = [{ cost: 0.05, inStock: true }, { cost: 0.15, inStock: true }, { cost: 0.3, inStock: true }]; + assert.deepEqual(api.filterPriceEntries(entries, { minPrice: '0.1', maxPrice: '0.2' }).map((x) => x.cost), [0.15]); +}); +test('normalizeActivation parses ACCESS_NUMBER response', () => { + const api = load(); + const activation = api.normalizeActivation('ACCESS_NUMBER:12345:628123456789', { countryId: 6, countryLabel: 'Indonesia' }); + assert.equal(activation.activationId, '12345'); + assert.equal(activation.phoneNumber, '628123456789'); + assert.equal(activation.countryLabel, 'Indonesia'); +}); +test('extractVerificationCode parses STATUS_OK response', () => { + const api = load(); + assert.equal(api.extractVerificationCode('STATUS_OK: Your code is 654321'), '654321'); + assert.equal(api.extractVerificationCode('STATUS_OK:987654'), '987654'); +}); +test('FlowPilot-style phone failure classifiers choose release action', () => { + const api = load(); + assert.equal(api.isPhoneNumberUsedError('该电话已被使用,请换一个号码'), true); + assert.equal(api.isPhoneNumberDeliveryRefusedError('无法向此电话号码发送验证码'), true); + assert.equal(api.getPhoneReplacementReleaseAction('phone_number_used'), 'ban'); + assert.equal(api.getPhoneReplacementReleaseAction('phone_delivery_refused'), 'cancel'); +}); +test('fetchNextPhone releases previous activation then acquires replacement', async () => { + const requests = []; + const stored = { + apiKey: 'demo-key', + baseUrl: 'https://smsbower.app/stubs/handler_api.php', + serviceCode: 'dr', + minPrice: '0.1', + maxPrice: '0.3', + countries: [{ id: 6, label: 'Indonesia' }], + activeActivation: { activationId: 'old-activation', phoneNumber: '628111', countryId: 6 }, + }; + const api = load({ + chrome: { + action: { onClicked: { addListener() {} } }, + tabs: { async sendMessage() {} }, + scripting: { async executeScript() {} }, + runtime: { onMessage: { addListener() {} } }, + storage: { local: { async get() { return stored; }, async set(update) { Object.assign(stored, update); } } } + }, + fetch: async (url) => { + const parsed = new URL(url); + const action = parsed.searchParams.get('action'); + requests.push([action, parsed.searchParams.get('id'), parsed.searchParams.get('status'), parsed.searchParams.get('country')]); + if (action === 'setStatus') return new Response('ACCESS_CANCEL', { status: 200 }); + if (action === 'getPricesV3') return new Response(JSON.stringify({ 6: { dr: { '0.2': { count: 2 } } } }), { status: 200 }); + if (action === 'getNumberV2') return new Response(JSON.stringify({ activationId: 'new-activation', phoneNumber: '628222', countryCode: 6 }), { status: 200 }); + throw new Error(`unexpected ${url}`); + }, + Response, + }); + const result = await api.fetchNextPhone({ reason: 'phone_number_used', releaseAction: 'ban' }); + assert.equal(result.activation.activationId, 'new-activation'); + assert.equal(result.releasePrevious.releaseAction, 'ban'); + assert.equal(stored.activeActivation.previousActivationId, 'old-activation'); + assert.deepEqual(requests, [ + ['setStatus', 'old-activation', '8', null], + ['getPricesV3', null, null, '6'], + ['getNumberV2', null, null, '6'], + ]); +});