Hướng Dẫn Thực Hành

Xử lý CAPTCHA trong ứng dụng PWA (Progressive Web App)

CAPTCHA trong Progressive Web App (PWA) không nằm sẵn trong HTML khi trang vừa load — nó được framework render sau, đôi khi vài giây sau khi networkidle bắn xong. Script phát hiện chạy quá sớm luôn trả về null dù widget đang hiển thị ngay sau đó. CaptchaAI lo phần giải reCAPTCHA v2 và Cloudflare Turnstile, nhưng phần khó là bắt đúng thời điểm CAPTCHA xuất hiện, xử lý cache Service Worker, và theo dõi điều hướng phía client. Bài này đi qua bốn bước bằng Playwright, cộng bản Python dùng Selenium:

  1. Chờ CAPTCHA render động rồi mới trích xuất sitekey.
  2. Vượt qua cache của Service Worker cho các resource liên quan đến CAPTCHA.
  3. Theo dõi route đổi phía client để không bỏ lỡ CAPTCHA xuất hiện sau điều hướng.
  4. Gửi sitekey và pageurl cho CaptchaAI, polling lấy token rồi tiêm vào form.

Vì sao CAPTCHA trong PWA khó bắt hơn trang web thường

Trang web truyền thống trả CAPTCHA ngay trong phản hồi HTML đầu tiên. PWA khác ở vài điểm mấu chốt:

  • Thời điểm CAPTCHA xuất hiện: trang thường có sẵn trong HTML; PWA để framework render bằng JavaScript sau khi load.
  • Điều hướng trang: trang thường load lại toàn bộ; PWA định tuyến phía client, không load lại.
  • Service Worker: trang thường không có; PWA dùng nó cache tài nguyên, có thể chặn request.
  • DOM sẵn sàng: trang thường gần như ngay lập tức; PWA chỉ sẵn sàng sau khi render xong.
  • Request mạng: trang thường đi trực tiếp; PWA có thể bị Service Worker chặn.

Ví dụ thực tế: một nhóm QA tại công ty outsource ở TP.HCM test luồng checkout của một PWA thương mại điện tử trên staging và thấy script cũ liên tục báo "không tìm thấy CAPTCHA". Nguyên nhân không phải sitekey sai — script chạy ngay sau page.goto(), trong khi CAPTCHA chỉ được component giỏ hàng render ra sau khi gọi API lấy giỏ hàng xong. Vấn đề nằm ở thời điểm trích xuất, không nằm ở việc giải.

Bước 1: Bắt CAPTCHA render động bằng MutationObserver

Sai lầm phổ biến nhất là trích xuất sitekey ngay sau page.goto(), trước khi framework của PWA kịp render widget CAPTCHA. Dùng MutationObserver hoặc tín hiệu riêng của framework để chờ đúng thời điểm. Hai tín hiệu đáng tin cậy nhất:

  • CAPTCHA đã tồn tại sẵn trong DOM (kiểm tra ngay khi vào trang).
  • CAPTCHA được thêm vào sau, qua các node mới mà MutationObserver bắt được.
// pwa_captcha_detector.js — Playwright script
const { chromium } = require('playwright');
const axios = require('axios');

const API_KEY = 'YOUR_API_KEY';

async function detectCaptchaInPWA(page) {
  // Wait for the PWA app shell to render
  await page.waitForLoadState('networkidle');

  // Use MutationObserver to detect dynamically loaded CAPTCHAs
  const captchaInfo = await page.evaluate(() => {
    return new Promise((resolve) => {
      // Check if CAPTCHA is already present
      const existing = document.querySelector('.g-recaptcha, .cf-turnstile');
      if (existing) {
        resolve({
          type: existing.classList.contains('g-recaptcha')
            ? 'recaptcha_v2' : 'turnstile',
          sitekey: existing.getAttribute('data-sitekey'),
          pageurl: window.location.href,
        });
        return;
      }

      // Watch for CAPTCHA elements added dynamically
      const observer = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
          for (const node of mutation.addedNodes) {
            if (node.nodeType !== 1) continue;
            const captcha = node.matches?.('.g-recaptcha, .cf-turnstile')
              ? node
              : node.querySelector?.('.g-recaptcha, .cf-turnstile');
            if (captcha) {
              observer.disconnect();
              resolve({
                type: captcha.classList.contains('g-recaptcha')
                  ? 'recaptcha_v2' : 'turnstile',
                sitekey: captcha.getAttribute('data-sitekey'),
                pageurl: window.location.href,
              });
              return;
            }
          }
        }
      });

      observer.observe(document.body, {
        childList: true,
        subtree: true,
      });

      // Timeout after 15 seconds
      setTimeout(() => {
        observer.disconnect();
        resolve(null);
      }, 15000);
    });
  });

  return captchaInfo;
}

async function main() {
  const browser = await chromium.launch({ headless: false });
  const context = await browser.newContext();
  const page = await context.newPage();

  await page.goto('https://example-pwa.com/login');

  const captcha = await detectCaptchaInPWA(page);

  if (!captcha) {
    console.log('No CAPTCHA detected');
    await browser.close();
    return;
  }

  console.log(`Detected ${captcha.type}: ${captcha.sitekey}`);

  // Solve with CaptchaAI
  const token = await solveCaptcha(captcha);
  console.log(`Token: ${token.substring(0, 50)}...`);

  // Inject token
  await injectToken(page, captcha.type, token);

  // Submit form
  await page.click('button[type="submit"]');
  await page.waitForNavigation({ waitUntil: 'networkidle' });

  console.log('Form submitted');
  await browser.close();
}

async function solveCaptcha(captcha) {
  const params = {
    key: API_KEY,
    pageurl: captcha.pageurl,
    json: '1',
  };

  if (captcha.type === 'recaptcha_v2') {
    params.method = 'userrecaptcha';
    params.googlekey = captcha.sitekey;
  } else {
    params.method = 'turnstile';
    params.sitekey = captcha.sitekey;
  }

  const submit = await axios.get(
    'https://ocr.captchaai.com/in.php', { params }
  );
  if (submit.data.status !== 1) throw new Error(submit.data.request);

  const taskId = submit.data.request;

  for (let i = 0; i < 30; i++) {
    await new Promise((r) => setTimeout(r, 5000));
    const poll = await axios.get('https://ocr.captchaai.com/res.php', {
      params: { key: API_KEY, action: 'get', id: taskId, json: '1' },
    });
    if (poll.data.status === 1) return poll.data.request;
    if (poll.data.request !== 'CAPCHA_NOT_READY') {
      throw new Error(poll.data.request);
    }
  }
  throw new Error('Timeout');
}

async function injectToken(page, type, token) {
  if (type === 'recaptcha_v2') {
    await page.evaluate((t) => {
      document.getElementById('g-recaptcha-response').value = t;
      try {
        const clients = ___grecaptcha_cfg.clients;
        Object.keys(clients).forEach((k) => {
          Object.keys(clients[k]).forEach((j) => {
            if (clients[k][j]?.callback) clients[k][j].callback(t);
          });
        });
      } catch (e) {}
    }, token);
  } else {
    await page.evaluate((t) => {
      const input = document.querySelector('[name="cf-turnstile-response"]');
      if (input) input.value = t;
      const cb = document.querySelector('.cf-turnstile')
        ?.getAttribute('data-callback');
      if (cb && typeof window[cb] === 'function') window[cb](t);
    }, token);
  }
}

main().catch(console.error);

Bước 2: Vượt qua cache của Service Worker

Service Worker có thể cache sẵn script CAPTCHA, khiến widget hiển thị là bản cũ. Bỏ qua cache khi cần bằng cách chặn request và ép header no-cache:

// Intercept and bypass Service Worker cache for CAPTCHA scripts
await page.route('**/recaptcha/**', (route) => {
  route.continue({ headers: { ...route.request().headers(), 'Cache-Control': 'no-cache' } });
});

await page.route('**/turnstile/**', (route) => {
  route.continue({ headers: { ...route.request().headers(), 'Cache-Control': 'no-cache' } });
});

Chỉ áp header no-cache cho route CAPTCHA cụ thể — ép no-cache toàn site sẽ làm chậm asset khác của PWA.

Bước 3: Theo dõi điều hướng phía client

PWA dùng router phía client — điều hướng tới route được bảo vệ bằng CAPTCHA không kích hoạt page load, nên các listener load truyền thống sẽ bỏ lỡ sự kiện này hoàn toàn. Theo dõi thay đổi route trực tiếp. Áp dụng cho:

  • Router dạng history API (pushState/popState) — phổ biến ở React Router, Vue Router.
  • Route thay đổi qua hash (#/checkout) — vẫn cần lắng nghe sự kiện tương ứng.
// Monitor PWA route changes for new CAPTCHAs
await page.evaluate(() => {
  const originalPushState = history.pushState;
  history.pushState = function() {
    originalPushState.apply(this, arguments);
    window.dispatchEvent(new Event('pwa-route-change'));
  };
});

page.on('console', async (msg) => {
  // React to route changes if needed
});

// Or wait for specific route
await page.waitForURL('**/checkout', { waitUntil: 'networkidle' });
// Then detect CAPTCHA on the new route
const captcha = await detectCaptchaInPWA(page);

Bước 4: Phiên bản Python dùng Selenium

Nếu team dùng Python thay vì Node.js, luồng xử lý tương tự với Selenium và WebDriverWait:

# pwa_captcha_selenium.py
import time
import requests
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

API_KEY = "YOUR_API_KEY"

driver = webdriver.Chrome()
driver.get("https://example-pwa.com/login")

# Wait for PWA to render CAPTCHA
wait = WebDriverWait(driver, 20)
captcha_el = wait.until(
    EC.presence_of_element_located((By.CSS_SELECTOR, ".g-recaptcha, .cf-turnstile"))
)

sitekey = captcha_el.get_attribute("data-sitekey")
pageurl = driver.current_url
is_turnstile = "cf-turnstile" in captcha_el.get_attribute("class")

# Submit to CaptchaAI
params = {"key": API_KEY, "pageurl": pageurl, "json": "1"}
if is_turnstile:
    params["method"] = "turnstile"
    params["sitekey"] = sitekey
else:
    params["method"] = "userrecaptcha"
    params["googlekey"] = sitekey

resp = requests.get("https://ocr.captchaai.com/in.php", params=params)
task_id = resp.json()["request"]

# Poll
for _ in range(30):
    time.sleep(5)
    poll = requests.get("https://ocr.captchaai.com/res.php", params={
        "key": API_KEY, "action": "get", "id": task_id, "json": "1",
    })
    if poll.json().get("status") == 1:
        token = poll.json()["request"]
        break
else:
    raise TimeoutError("CAPTCHA not solved")

# Inject token
driver.execute_script(f"""
    document.getElementById('g-recaptcha-response').value = '{token}';
""")

driver.find_element(By.CSS_SELECTOR, 'button[type="submit"]').click()
print("Form submitted")
driver.quit()

Đảm bảo token còn hiệu lực trước khi submit

  • Vô hiệu hóa token CAPTCHA đã cache khi route đổi, trạng thái đăng nhập đổi, hoặc Service Worker refresh làm thay đổi context của trang.
  • Chỉ lưu phần state tối thiểu cần thiết để gắn token đã giải vào đúng view đang hoạt động.
  • Kiểm tra token còn mới ngay trước khi submit, thay vì giả định giải pháp đã lưu từ trước vẫn còn dùng được.

Các lỗi thường gặp khi xử lý CAPTCHA trong PWA

  1. Không bao giờ thấy phần tử CAPTCHA. Nguyên nhân: PWA chưa render route đó. Xử lý: waitForSelector timeout dài hơn, đảm bảo định tuyến client đã xong.
  2. Sitekey cũ sau khi điều hướng. Nguyên nhân: Service Worker trả HTML đã cache. Xử lý: bỏ header cache cho resource CAPTCHA như Bước 2.
  3. Không tìm thấy callback để tiêm token. Nguyên nhân: framework quản lý state riêng. Xử lý: kiểm tra state của React/Vue/Angular, trigger cập nhật form.
  4. Submit form không gửi token. Nguyên nhân: form handler đọc từ component state, không phải DOM. Xử lý: cập nhật cả state framework (React ref, Vue reactive property).

Câu hỏi thường gặp

Vì sao phải dùng MutationObserver thay vì chỉ đợi trang load xong?

Vì PWA không tải lại trang khi CAPTCHA xuất hiện. Sự kiện load/networkidle bắn xong không đồng nghĩa CAPTCHA đã có trong DOM — framework có thể render widget trễ vài giây, nên cần observer theo dõi DOM thay vì một mốc thời gian cố định.

Có nên tắt Service Worker khi chạy automation không?

Có thể, nếu bạn kiểm soát môi trường test — tắt hẳn Service Worker loại bỏ rủi ro cache cũ. Nếu cần giữ Service Worker sát với production, dùng header Cache-Control: no-cache riêng cho request CAPTCHA như Bước 2.

Token CaptchaAI có khác gì giữa PWA và trang web thường không?

Không. Token từ in.php/res.php giống hệt nhau dù CAPTCHA hiển thị trong PWA hay trang truyền thống, vì CaptchaAI chỉ cần sitekey và pageurl.

Giải CAPTCHA trong luồng test PWA tốn bao nhiêu?

CaptchaAI tính phí theo thread, không theo từng lần giải — gói BASIC ($15/tháng, 5 thread) đã đủ cho hầu hết pipeline QA chạy tuần tự trên một PWA. Nếu chạy nhiều luồng test song song, tăng lên STANDARD ($30/tháng, 15 thread) hoặc ADVANCE ($90/tháng, 50 thread) tùy quy mô.

Bài viết liên quan

Bước tiếp theo

Bắt đầu giải CAPTCHA trong PWA — lấy API key CaptchaAI, tích hợp phát hiện động vào pipeline automation của bạn, và tham khảo thêm các hướng dẫn liên quan:

Os comentários estão desativados para este artigo.