瀏覽器擴展開發:一鍵填充從虛擬號碼讀取的短信驗證碼

一個專注瀏覽器擴展的資深前端工程師的「從零到發布」完整實戰——每一步都有代碼,每一段代碼都有註釋,每一個坑都提前替你踩平了。

真實場景:你在註冊某網站,切換到接碼平台等驗證碼,再切回來手動輸入,反覆橫跳。這個擴展讓你點擊圖標、點擊「獲取驗證碼」,驗證碼自動出現在輸入框裡——全程在瀏覽器內完成,無需切換設備。

一、擴展架構圖

三個核心組件各司其職,數據流清晰分明:

[Popup UI] ←→ [Background Service Worker] ←→ [接碼平台 API] ↓ [Content Script] → [目標網頁驗證碼輸入框] ← 自動填充

二、Step 1:項目初始化與 Manifest V3 配置

建立專案目錄,初始文件結構如下:

sms-filler-extension/
├── manifest.json
├── popup.html
├── popup.js
├── popup.css
├── background.js
├── content-script.js
├── services/
│   └── sms-api.js
└── icons/
    ├── icon16.png
    ├── icon48.png
    └── icon128.png

以下是完整的 manifest.json,注意 Manifest V3 的關鍵權限聲明:

{
  "manifest_version": 3,
  "name": "SMS Auto Fill",
  "version": "1.0.0",
  "description": "一鍵從虛擬號碼讀取短信驗證碼並自動填充",
  "permissions": [
    "storage",
    "scripting",
    "activeTab",
    "notifications",
    "alarms"
  ],
  "host_permissions": [
    "https://5sim.net/*",
    "https://api.smspool.net/*"
  ],
  "background": {
    "service_worker": "background.js"
  },
  "action": {
    "default_popup": "popup.html",
    "default_title": "SMS Auto Fill"
  },
  "icons": {
    "16": "icons/icon16.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  },
  "content_scripts": [
    {
      "matches": [""],
      "js": ["content-script.js"],
      "run_at": "document_idle"
    }
  ]
}

Manifest V3 與 V2 的關鍵差異:

特性Manifest V2Manifest V3
Background持久背景頁Service Worker(非持久,可能休眠)
權限權限較寬鬆需顯式聲明 host_permissions
遠端代碼允許禁止,所有邏輯必須打包在擴展內
生命週期背景頁長駐Service Worker 閒置 30 秒後卸載

三、Step 2:核心 API 通信模組——對接接碼平台

services/sms-api.js 中封裝獲取號碼、輪詢短信、釋放號碼三個核心函數。以 5sim API 為例,錯誤處理涵蓋超時、餘額不足、號碼被封等情況。

const BASE_URL = 'https://5sim.net/v1';
let API_KEY = '';

// 初始化 API Key(從 storage 讀取)
async function initApiKey() {
  const result = await chrome.storage.local.get('apiKey');
  API_KEY = result.apiKey || '';
}
initApiKey();

// 請求頭
function headers() {
  return {
    'Authorization': `Bearer ${API_KEY}`,
    'Accept': 'application/json'
  };
}

// 獲取虛擬號碼 (country: usa, service: google)
async function buyNumber(country = 'usa', service = 'google') {
  try {
    const resp = await fetch(
      `${BASE_URL}/user/buy/activation/${country}/any/${service}`,
      { headers: headers() }
    );
    if (!resp.ok) {
      if (resp.status === 403) throw new Error('API Key 無效或餘額不足');
      if (resp.status === 429) throw new Error('請求過於頻繁,請稍後重試');
      throw new Error(`獲取號碼失敗: ${resp.status}`);
    }
    const data = await resp.json();
    return { phone: data.phone, activationId: data.id };
  } catch (e) {
    console.error('buyNumber error:', e);
    throw e;
  }
}

// 輪詢短信狀態 (每 5 秒一次,最長 120 秒)
async function waitForSms(activationId, timeout = 120000) {
  const start = Date.now();
  while (Date.now() - start < timeout) {
    try {
      const resp = await fetch(`${BASE_URL}/user/check/${activationId}`, {
        headers: headers()
      });
      if (!resp.ok) throw new Error(`查詢失敗: ${resp.status}`);
      const data = await resp.json();
      if (data.status === 'RECEIVED' && data.sms && data.sms.length > 0) {
        return data.sms[0].text; // 返回最新短信內容
      }
    } catch (e) {
      console.warn('輪詢錯誤:', e);
    }
    await new Promise(resolve => setTimeout(resolve, 5000));
  }
  throw new Error('等待短信超時');
}

// 釋放號碼(取消或標記完成)
async function cancelNumber(activationId) {
  try {
    await fetch(`${BASE_URL}/user/cancel/${activationId}`, {
      headers: headers()
    });
  } catch (e) {
    console.error('釋放號碼失敗:', e);
  }
}

// 導出給 background 使用
export { buyNumber, waitForSms, cancelNumber };

四、Step 3:構建 Popup UI——控制面板

Popup 是使用者直接互動的介面。它通過 chrome.storage.local 與 Background 雙向同步數據,實時顯示號碼與驗證碼。

popup.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="popup.css">
</head>
<body>
  <div class="container">
    <div class="section">
      <div class="label">📞 當前號碼</div>
      <div id="phoneDisplay" class="phone">尚無號碼</div>
      <div id="expiryDisplay" class="expiry"></div>
    </div>
    <div class="section">
      <div class="label">🔑 最新驗證碼</div>
      <div id="otpDisplay" class="otp">等待中...</div>
      <button id="copyBtn" class="btn secondary">複製驗證碼</button>
    </div>
    <div class="actions">
      <button id="getNumberBtn" class="btn primary">獲取新號碼</button>
      <button id="fillBtn" class="btn success" disabled>填充至當前頁</button>
    </div>
    <div id="status" class="status"></div>
  </div>
  <script src="popup.js" type="module"></script>
</body>
</html>

popup.js(核心邏輯)

import { buyNumber, waitForSms } from './services/sms-api.js';

const phoneEl = document.getElementById('phoneDisplay');
const otpEl = document.getElementById('otpDisplay');
const expiryEl = document.getElementById('expiryDisplay');
const copyBtn = document.getElementById('copyBtn');
const getNumberBtn = document.getElementById('getNumberBtn');
const fillBtn = document.getElementById('fillBtn');
const statusEl = document.getElementById('status');

let currentPhone = '';
let currentActivationId = '';
let countdownTimer = null;

// 從 storage 加載狀態
async function loadState() {
  const { phone, activationId, otp, timestamp } = await chrome.storage.local.get(['phone', 'activationId', 'otp', 'timestamp']);
  if (phone) {
    currentPhone = phone;
    currentActivationId = activationId;
    phoneEl.textContent = phone;
    expiryEl.textContent = timestamp ? `取得時間: ${new Date(timestamp).toLocaleTimeString()}` : '';
    fillBtn.disabled = !otp;
  }
  if (otp) {
    otpEl.textContent = otp;
  }
}

loadState();

// 監聽 storage 變化實時更新
chrome.storage.onChanged.addListener((changes) => {
  if (changes.phone) {
    phoneEl.textContent = changes.phone.newValue || '尚無號碼';
    currentPhone = changes.phone.newValue;
  }
  if (changes.otp) {
    otpEl.textContent = changes.otp.newValue || '等待中...';
    fillBtn.disabled = !changes.otp.newValue;
    if (changes.otp.newValue) copyBtn.disabled = false;
  }
});

// 獲取新號碼
getNumberBtn.addEventListener('click', async () => {
  getNumberBtn.disabled = true;
  statusEl.textContent = '正在獲取號碼...';
  try {
    const { phone, activationId } = await buyNumber('usa', 'google');
    const timestamp = Date.now();
    await chrome.storage.local.set({ phone, activationId, otp: '', timestamp });
    currentPhone = phone;
    currentActivationId = activationId;
    statusEl.textContent = '✅ 號碼已獲取,正在等待短信...';
    // 觸發 background 開始輪詢
    chrome.runtime.sendMessage({ action: 'startPolling', activationId });

    // 8 分鐘倒計時
    startCountdown(480);
  } catch (e) {
    statusEl.textContent = `❌ ${e.message}`;
  } finally {
    getNumberBtn.disabled = false;
  }
});

// 填充到當前頁面
fillBtn.addEventListener('click', async () => {
  fillBtn.disabled = true;
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (tab) {
    chrome.tabs.sendMessage(tab.id, { action: 'fillOtp' }, (response) => {
      if (chrome.runtime.lastError) {
        statusEl.textContent = '❌ 無法注入此頁面(可能為 chrome:// 頁面)';
      } else {
        statusEl.textContent = '✓ 已填充';
        setTimeout(() => { statusEl.textContent = ''; }, 2000);
      }
      fillBtn.disabled = false;
    });
  }
});

// 複製驗證碼
copyBtn.addEventListener('click', () => {
  const otp = otpEl.textContent;
  if (otp && otp !== '等待中...') {
    navigator.clipboard.writeText(otp).then(() => {
      statusEl.textContent = '✓ 已複製';
      setTimeout(() => { statusEl.textContent = ''; }, 1500);
    });
  }
});

function startCountdown(seconds) {
  clearInterval(countdownTimer);
  let remaining = seconds;
  expiryEl.textContent = `有效期剩餘: ${Math.floor(remaining / 60)}:${(remaining % 60).toString().padStart(2, '0')}`;
  countdownTimer = setInterval(() => {
    remaining--;
    if (remaining <= 0) {
      clearInterval(countdownTimer);
      expiryEl.textContent = '⏰ 號碼已過期';
      return;
    }
    expiryEl.textContent = `有效期剩餘: ${Math.floor(remaining / 60)}:${(remaining % 60).toString().padStart(2, '0')}`;
  }, 1000);
}

五、Step 4:Content Script——自動填充驗證碼

Content Script 負責智能識別頁面上的驗證碼輸入框,並使用原生事件觸發來確保 React/Vue 受控組件正常更新。

// content-script.js

// 通用的 setNativeValue 函數,繞過 React/Vue 的屬性劫持
function setNativeValue(element, value) {
  const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
    window.HTMLInputElement.prototype, 'value'
  ).set;
  nativeInputValueSetter.call(element, value);
  element.dispatchEvent(new Event('input', { bubbles: true }));
  element.dispatchEvent(new Event('change', { bubbles: true }));
  // 某些框架需要額外觸發
  element.dispatchEvent(new FocusEvent('focus', { bubbles: true }));
  element.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
}

// 智能尋找驗證碼輸入框
function findOtpInput() {
  const keywords = ['code', 'token', 'otp', 'verification', 'captcha', 'mfa', 'pin', 'one-time'];
  const selectors = keywords.map(k => `input[type="text"][id*="${k}"], input[type="text"][name*="${k}"],
    input[type="text"][placeholder*="${k}"], input[type="tel"][id*="${k}"],
    input[autocomplete="one-time-code"], input[autocomplete="otp"]`).join(', ');

  let input = document.querySelector(selectors);

  if (!input) {
    // 遍歷所有 input,根據屬性關鍵字模糊匹配
    const allInputs = document.querySelectorAll('input[type="text"], input[type="tel"], input[type="number"]');
    for (const el of allInputs) {
      const attrs = (el.getAttribute('id') || '') + (el.getAttribute('name') || '') +
        (el.getAttribute('placeholder') || '') + (el.getAttribute('autocomplete') || '');
      if (keywords.some(k => attrs.toLowerCase().includes(k))) {
        input = el;
        break;
      }
    }
  }
  return input;
}

// 監聽來自 popup 或 background 的填充指令
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  if (request.action === 'fillOtp') {
    chrome.storage.local.get('otp', (data) => {
      const otp = data.otp;
      if (!otp) {
        sendResponse({ success: false, error: '無可用驗證碼' });
        return;
      }
      const input = findOtpInput();
      if (!input) {
        sendResponse({ success: false, error: '找不到驗證碼輸入框' });
        return;
      }
      setNativeValue(input, otp);
      // 短暫視覺反饋
      input.style.transition = 'background-color 0.3s';
      input.style.backgroundColor = '#c6f6d5';
      setTimeout(() => { input.style.backgroundColor = ''; }, 1500);
      sendResponse({ success: true });
    });
    return true; // 保持訊息通道開啟以進行非同步回應
  }
});

setNativeValue 的設計原因:React 和 Vue 使用受控組件,直接賦值 input.value = otp 不會觸發框架的狀態更新。我們必須取得原生 value setter 並主動觸發 inputchange 事件,框架才能正確同步內部狀態。

六、Step 5:打包與本地安裝

本地測試

  1. 打開 Chrome,網址列輸入 chrome://extensions/
  2. 開啟右上角「開發人員模式」。
  3. 點擊「載入未封裝項目」,選擇專案根目錄(包含 manifest.json 的文件夾)。
  4. 擴展圖標會出現在工具列,點擊即可測試。

打包發布

產生 .zip 包上傳 Chrome Web Store:

zip -r sms-filler.zip . -x "*.git*" "node_modules/*"

提交到開發者控制台時需準備:說明、截圖、隱私政策 URL、應用類別等。

七、進階功能優化建議

八、排坑指南

坑一:Content Script 無法注入某些頁面

症狀:chrome://extensions/ 或 Chrome Web Store 等特權頁面中,content script 無法執行,fill 按鈕點擊後無反應。

解法:在 popup.js 的 fillBtn 回調中檢測 chrome.runtime.lastError,並提示用戶「此頁面不支援自動填充」。此外,可在 manifest.jsoncontent_scripts.matches 中排除 chrome://https://chrome.google.com/webstore/*

坑二:React/Vue 輸入框值已更新但組件狀態未同步

症狀:驗證碼已顯示在輸入框內,但頁面上的「提交」按鈕仍為禁用狀態,表單校驗未通過。

解法:必須使用 setNativeValue 函數,通過原生 setter 設置值並觸發 inputchange 事件。僅設置 element.value 或僅觸發 input 事件可能不足以通知框架。

坑三:Background Service Worker 休眠

症狀:擴展安裝後一段時間,輪詢過程突然中斷,短信不再接收。這是因為 Manifest V3 的 Service Worker 在閒置約 30 秒後會被瀏覽器卸載。

解法:使用 chrome.alarms API 建立定時喚醒機制。在 background.js 中:

chrome.alarms.create('keepAlive', { periodInMinutes: 0.5 });
chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'keepAlive') {
    console.log('Service Worker 喚醒檢查');
    // 此處可檢查是否有正在進行的輪詢,若無需繼續則可省略
  }
});

同時在進行輪詢時,可通過 chrome.storage.session 保持少量數據以延長生命。

坑四:API Key 安全存儲

症狀:在 popup 或 background 中硬編碼 API Key,代碼一旦被解析即洩漏。

解法:讓使用者在擴展的選項頁面輸入 API Key,並存儲在 chrome.storage.local(本地加密存儲,不會同步到雲端)。每次調用 API 時從 storage 讀取。

九、總結與倫理提醒

至此,你已經擁有了一個完整的瀏覽器擴展,它可以在你瀏覽任何網站時,一鍵從接碼平台拉取虛擬號碼的短信驗證碼,並自動填充到正確的輸入框中。這套架構的核心價值在於:將接碼平台的能力融入瀏覽器,省去切換設備的認知負擔。

倫理聲明:本擴展僅供開發測試使用,不得用於任何形式的惡意批量註冊或違反網站服務條款的行為。技術讓驗證碼輸入更優雅,但倫理讓你走得更遠。請確保你的使用方式符合目標網站的政策,且不侵犯他人權益。