commit a9c7cf4ae170a485411f1342b7478a5b74c8f842 Author: chick Date: Sun May 31 16:05:42 2026 +0800 feat: add smsbower phone fill tool with rotation 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'], + ]); +});