Browse Source

Improve verification polling and localhost capture

wxwxOk 4 months ago
parent
commit
89e874f324
3 changed files with 193 additions and 68 deletions
  1. 109 24
      background.js
  2. 27 24
      content/mail-163.js
  3. 57 20
      content/signup-page.js

+ 109 - 24
background.js

@@ -10,6 +10,33 @@ const HUMAN_STEP_DELAY_MAX = 2200;
 
 initializeSessionStorageAccess();
 
+let automationWindowId = null;
+
+async function ensureAutomationWindowId() {
+  if (automationWindowId != null) {
+    try {
+      await chrome.windows.get(automationWindowId);
+      return automationWindowId;
+    } catch {
+      automationWindowId = null;
+    }
+  }
+  const registry = await getTabRegistry();
+  for (const entry of Object.values(registry)) {
+    if (entry.tabId) {
+      try {
+        const tab = await chrome.tabs.get(entry.tabId);
+        automationWindowId = tab.windowId;
+        return automationWindowId;
+      } catch {}
+    }
+  }
+  const win = await chrome.windows.getLastFocused();
+  automationWindowId = win.id;
+  return automationWindowId;
+}
+
+
 // ============================================================
 // State Management (chrome.storage.session)
 // ============================================================
@@ -307,8 +334,9 @@ async function reuseOrCreateTab(source, url, options = {}) {
     return tabId;
   }
 
-  // Create new tab
-  const tab = await chrome.tabs.create({ url, active: true });
+  // Create new tab in the automation window
+  const wid = await ensureAutomationWindowId();
+  const tab = await chrome.tabs.create({ url, active: true, windowId: wid });
   console.log(LOG_PREFIX, `Created new tab ${source} (${tab.id})`);
 
   // If dynamic injection needed (VPS panel), inject scripts after load
@@ -1065,7 +1093,28 @@ function normalizeInbucketOrigin(rawValue) {
   }
 }
 
+async function clickResendOnSignupPage(step) {
+  const signupTabId = await getTabId('signup-page');
+  if (!signupTabId) return;
+
+  await chrome.tabs.update(signupTabId, { active: true });
+  await sleepWithStop(500);
+
+  try {
+    await sendToContentScript('signup-page', {
+      type: 'CLICK_RESEND_EMAIL',
+      step,
+      source: 'background',
+    });
+  } catch (err) {
+    await addLog(`Step ${step}: Resend click skipped: ${err.message}`, 'warn');
+  }
+}
+
 async function executeStep4(state) {
+  // Click "重新发送电子邮件" on the signup page before polling
+  await clickResendOnSignupPage(4);
+
   const mail = getMailConfig(state);
   if (mail.error) throw new Error(mail.error);
   await addLog(`Step 4: Opening ${mail.label}...`);
@@ -1175,6 +1224,9 @@ async function executeStep6(state) {
 // ============================================================
 
 async function executeStep7(state) {
+  // Click "重新发送电子邮件" on the auth page before polling
+  await clickResendOnSignupPage(7);
+
   const mail = getMailConfig(state);
   if (mail.error) throw new Error(mail.error);
   await addLog(`Step 7: Opening ${mail.label}...`);
@@ -1245,47 +1297,67 @@ async function executeStep8(state) {
     throw new Error('No OAuth URL. Complete step 1 first.');
   }
 
+  // Check if the signup tab already redirected to localhost before listener setup
+  const signupTabIdEarly = await getTabId('signup-page');
+  if (signupTabIdEarly) {
+    try {
+      const tab = await chrome.tabs.get(signupTabIdEarly);
+      if (tab.url && (tab.url.startsWith('http://localhost') || tab.url.startsWith('http://127.0.0.1'))) {
+        await addLog(`Step 8: Localhost redirect already captured: ${tab.url}`, 'ok');
+        await setState({ localhostUrl: tab.url });
+        broadcastDataUpdate({ localhostUrl: tab.url });
+        return;
+      }
+    } catch {}
+  }
+
   await addLog('Step 8: Setting up localhost redirect listener...');
 
   // Register webNavigation listener (scoped to this step)
   return new Promise((resolve, reject) => {
     let resolved = false;
-    let resolveCaptureWait = null;
-    const captureWait = new Promise((resolveCapture) => {
-      resolveCaptureWait = resolveCapture;
-    });
 
-    const cleanupListener = () => {
+    const isLocalhostUrl = (url) =>
+      url && (url.startsWith('http://localhost') || url.startsWith('http://127.0.0.1'));
+
+    const cleanupListeners = () => {
       if (webNavListener) {
         chrome.webNavigation.onBeforeNavigate.removeListener(webNavListener);
+        chrome.webNavigation.onCommitted.removeListener(webNavListener);
+        chrome.webNavigation.onErrorOccurred.removeListener(webNavListener);
         webNavListener = null;
       }
     };
 
+    const captureLocalhostUrl = (url) => {
+      if (resolved) return;
+      resolved = true;
+      cleanupListeners();
+      clearTimeout(timeout);
+      setState({ localhostUrl: url }).then(() => {
+        addLog(`Step 8: Captured localhost URL: ${url}`, 'ok');
+        setStepStatus(8, 'completed');
+        notifyStepComplete(8, { localhostUrl: url });
+        broadcastDataUpdate({ localhostUrl: url });
+        resolve();
+      });
+    };
+
     const timeout = setTimeout(() => {
-      cleanupListener();
+      cleanupListeners();
       reject(new Error('Localhost redirect not captured after 120s. Step 8 click may have been blocked.'));
     }, 120000);
 
     webNavListener = (details) => {
-      if (details.url.startsWith('http://localhost')) {
+      if (details.frameId === 0 && isLocalhostUrl(details.url)) {
         console.log(LOG_PREFIX, `Captured localhost redirect: ${details.url}`);
-        resolved = true;
-        cleanupListener();
-        clearTimeout(timeout);
-        if (resolveCaptureWait) resolveCaptureWait(details.url);
-
-        setState({ localhostUrl: details.url }).then(() => {
-          addLog(`Step 8: Captured localhost URL: ${details.url}`, 'ok');
-          setStepStatus(8, 'completed');
-          notifyStepComplete(8, { localhostUrl: details.url });
-          broadcastDataUpdate({ localhostUrl: details.url });
-          resolve();
-        });
+        captureLocalhostUrl(details.url);
       }
     };
 
     chrome.webNavigation.onBeforeNavigate.addListener(webNavListener);
+    chrome.webNavigation.onCommitted.addListener(webNavListener);
+    chrome.webNavigation.onErrorOccurred.addListener(webNavListener);
 
     // After step 7, the auth page shows a consent screen ("使用 ChatGPT 登录到 Codex")
     // with a "继续" button. We locate the button in-page, then click it through
@@ -1314,10 +1386,22 @@ async function executeStep8(state) {
         if (!resolved) {
           await clickWithDebugger(signupTabId, clickResult?.rect);
           await addLog('Step 8: Debugger click dispatched, waiting for redirect...');
+
+          // Fallback: poll tab URL in case webNavigation listeners missed the redirect
+          for (let i = 0; i < 30 && !resolved; i++) {
+            await new Promise(r => setTimeout(r, 1000));
+            try {
+              const tab = await chrome.tabs.get(signupTabId);
+              if (isLocalhostUrl(tab.url)) {
+                captureLocalhostUrl(tab.url);
+                break;
+              }
+            } catch { break; }
+          }
         }
       } catch (err) {
         clearTimeout(timeout);
-        cleanupListener();
+        cleanupListeners();
         reject(err);
       }
     })();
@@ -1342,8 +1426,9 @@ async function executeStep9(state) {
   const alive = tabId && await isTabAlive('vps-panel');
 
   if (!alive) {
-    // Create new tab
-    const tab = await chrome.tabs.create({ url: state.vpsUrl, active: true });
+    // Create new tab in the automation window
+    const wid = await ensureAutomationWindowId();
+    const tab = await chrome.tabs.create({ url: state.vpsUrl, active: true, windowId: wid });
     tabId = tab.id;
     await new Promise(resolve => {
       const listener = (tid, info) => {

+ 27 - 24
content/mail-163.js

@@ -69,6 +69,15 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
 // Find mail items
 // ============================================================
 
+function parseEmailDate(item) {
+  const aria = item.getAttribute('aria-label') || '';
+  const m = aria.match(/(\d{4})[年/-](\d{1,2})[月/-](\d{1,2})[日]?\s*(\d{1,2}):(\d{2})/);
+  if (m) return new Date(+m[1], m[2] - 1, +m[3], +m[4], +m[5]).getTime();
+  const d = aria.match(/(\d{4})[年/-](\d{1,2})[月/-](\d{1,2})/);
+  if (d) return new Date(+d[1], d[2] - 1, +d[3]).getTime();
+  return 0;
+}
+
 function findMailItems() {
   return document.querySelectorAll('div[sign="letter"]');
 }
@@ -87,7 +96,7 @@ function getCurrentMailIds() {
 // ============================================================
 
 async function handlePollEmail(step, payload) {
-  const { senderFilters, subjectFilters, maxAttempts, intervalMs } = payload;
+  const { senderFilters, subjectFilters, maxAttempts, intervalMs, filterAfterTimestamp = 0 } = payload;
 
   log(`Step ${step}: Starting email poll on 163 Mail (max ${maxAttempts} attempts)`);
 
@@ -101,28 +110,16 @@ async function handlePollEmail(step, payload) {
     log(`Step ${step}: Inbox link not found, proceeding...`, 'warn');
   }
 
-  // Wait for mail list to appear
+  // Wait for mail list container to appear (page loaded check, inbox can be empty)
   log(`Step ${step}: Waiting for mail list...`);
-  let items = [];
-  for (let i = 0; i < 20; i++) {
-    items = findMailItems();
-    if (items.length > 0) break;
-    await sleep(500);
-  }
-
-  if (items.length === 0) {
-    await refreshInbox();
-    await sleep(2000);
-    items = findMailItems();
-  }
-
-  if (items.length === 0) {
-    throw new Error('163 Mail list did not load. Make sure inbox is open.');
+  try {
+    await waitForElement('.nui-tree-item-text[title="收件箱"], .mail-list, div[sign="letter"]', 10000);
+    log(`Step ${step}: Mail page loaded`);
+  } catch {
+    log(`Step ${step}: Mail page may not be fully loaded, proceeding to poll anyway...`, 'warn');
   }
 
-  log(`Step ${step}: Mail list loaded, ${items.length} items`);
-
-  // Snapshot existing mail IDs
+  // Snapshot existing mail IDs (may be empty if inbox is empty)
   const existingMailIds = getCurrentMailIds();
   log(`Step ${step}: Snapshotted ${existingMailIds.size} existing emails`);
 
@@ -131,10 +128,8 @@ async function handlePollEmail(step, payload) {
   for (let attempt = 1; attempt <= maxAttempts; attempt++) {
     log(`Polling 163 Mail... attempt ${attempt}/${maxAttempts}`);
 
-    if (attempt > 1) {
-      await refreshInbox();
-      await sleep(1000);
-    }
+    await refreshInbox();
+    await sleep(1000);
 
     const allItems = findMailItems();
     const useFallback = attempt > FALLBACK_AFTER;
@@ -155,6 +150,14 @@ async function handlePollEmail(step, payload) {
       const senderMatch = senderFilters.some(f => sender.includes(f.toLowerCase()) || ariaLabel.includes(f.toLowerCase()));
       const subjectMatch = subjectFilters.some(f => subject.toLowerCase().includes(f.toLowerCase()) || ariaLabel.includes(f.toLowerCase()));
 
+      if ((senderMatch || subjectMatch) && filterAfterTimestamp > 0) {
+        const emailTime = parseEmailDate(item);
+        if (emailTime > 0 && emailTime < filterAfterTimestamp - 60000) {
+          log(`Step ${step}: Skipping old email (date: ${new Date(emailTime).toLocaleString()})`, 'info');
+          continue;
+        }
+      }
+
       if (senderMatch || subjectMatch) {
         const code = extractVerificationCode(subject + ' ' + ariaLabel);
         if (code && !seenCodes.has(code)) {

+ 57 - 20
content/signup-page.js

@@ -5,7 +5,7 @@ console.log('[MultiPage:signup-page] Content script loaded on', location.href);
 
 // Listen for commands from Background
 chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
-  if (message.type === 'EXECUTE_STEP' || message.type === 'FILL_CODE' || message.type === 'STEP8_FIND_AND_CLICK') {
+  if (message.type === 'EXECUTE_STEP' || message.type === 'FILL_CODE' || message.type === 'STEP8_FIND_AND_CLICK' || message.type === 'CLICK_RESEND_EMAIL') {
     resetStopState();
     handleCommand(message).then((result) => {
       sendResponse({ ok: true, ...(result || {}) });
@@ -43,6 +43,8 @@ async function handleCommand(message) {
     case 'FILL_CODE':
       // Step 4 = signup code, Step 7 = login code (same handler)
       return await fillVerificationCode(message.step, message.payload);
+    case 'CLICK_RESEND_EMAIL':
+      return await clickResendEmail(message.step);
     case 'STEP8_FIND_AND_CLICK':
       return await step8_findAndClick();
   }
@@ -149,6 +151,38 @@ async function step3_fillEmailPassword(payload) {
   }
 }
 
+// ============================================================
+// Click "重新发送电子邮件" (used before step 4 and step 7 polling)
+// ============================================================
+
+async function clickResendEmail(step) {
+  log(`Step ${step}: Looking for "重新发送电子邮件" button...`);
+
+  let resendBtn = null;
+  try {
+    resendBtn = await waitForElementByText(
+      'a, button, [role="button"], [role="link"], span',
+      /重新发送电子邮件|resend\s*email/i,
+      10000
+    );
+  } catch {
+    log(`Step ${step}: "重新发送电子邮件" button not found, skipping`, 'warn');
+    return;
+  }
+
+  // Prevent parent form POST submission (Remix/React Router route without action)
+  const parentForm = resendBtn.closest('form');
+  const blockSubmit = (e) => e.preventDefault();
+  if (parentForm) parentForm.addEventListener('submit', blockSubmit, { once: true });
+
+  await humanPause(400, 1000);
+  resendBtn.click();
+  log(`Step ${step}: Clicked "重新发送电子邮件"`, 'ok');
+  await sleep(2000);
+
+  if (parentForm) parentForm.removeEventListener('submit', blockSubmit);
+}
+
 // ============================================================
 // Fill Verification Code (used by step 4 and step 7)
 // ============================================================
@@ -260,34 +294,17 @@ async function step6_login(payload) {
   reportComplete(6, { needsOTP: true });
 }
 
-async function waitForLoginPasswordField(timeout = 15000) {
+async function waitForLoginPasswordField(timeout = 25000) {
   const start = Date.now();
 
   while (Date.now() - start < timeout) {
     throwIfStopped();
 
-    const passwordInput = document.querySelector('input[type="password"]');
+    const passwordInput = findVisiblePasswordInput();
     if (passwordInput) {
       return passwordInput;
     }
 
-    // Some flows skip the password screen and go straight to OTP or consent.
-    const otpInput = document.querySelector(
-      'input[name="code"], input[name="otp"], input[type="text"][maxlength="6"], input[inputmode="numeric"], input[maxlength="1"]'
-    );
-    if (otpInput) {
-      log('Step 6: Verification code input appeared before password field.');
-      return null;
-    }
-
-    const consentButton = document.querySelector(
-      'button[type="submit"][data-dd-action-name="Continue"], button[type="submit"]._primary_3rdp0_107'
-    );
-    if (consentButton) {
-      log('Step 6: Consent page appeared before password field.');
-      return null;
-    }
-
     await sleep(250);
   }
 
@@ -295,6 +312,26 @@ async function waitForLoginPasswordField(timeout = 15000) {
   return null;
 }
 
+function findVisiblePasswordInput() {
+  const inputs = document.querySelectorAll('input[type="password"]');
+  for (const input of inputs) {
+    if (isElementVisible(input)) {
+      return input;
+    }
+  }
+  return null;
+}
+
+function isElementVisible(el) {
+  if (!el) return false;
+  const style = window.getComputedStyle(el);
+  if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
+    return false;
+  }
+  const rect = el.getBoundingClientRect();
+  return rect.width > 0 && rect.height > 0;
+}
+
 // ============================================================
 // Step 8: Find "继续" on OAuth consent page for debugger click
 // ============================================================