Просмотр исходного кода

Add automated login to Airbnb refresh script

Implements performLogin with cold/warm session detection:
- cold: fills email, selects password auth via 'Try another way' flow
- warm: lands directly on password page (device already known)
Waits for authenticate/password response before navigating to
multicalendar, with rate limiting and per-session lockout guard.
ZiB 4 месяцев назад
Родитель
Сommit
b47996dd50
1 измененных файлов с 127 добавлено и 4 удалено
  1. 127 4
      room_cal/service_api/typescript/airbnb/refresh-cookies.ts

+ 127 - 4
room_cal/service_api/typescript/airbnb/refresh-cookies.ts

@@ -1,6 +1,7 @@
 import * as fs from 'fs';
 import * as path from 'path';
 import * as os from 'os';
+import type { Page } from 'playwright';
 import { filterCookies, parseOutputMode } from '../shared/cookie-utils';
 import { handleOutput } from '../shared/api-client';
 import { openPersistentContext, profileDir } from '../shared/browser';
@@ -8,13 +9,123 @@ import type { CredentialsPayload } from '../shared/types';
 
 const DATA_HOME = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
 const ENV_PATH = path.join(DATA_HOME, 'airbnb.env');
+const LOGIN_ATTEMPTS_PATH = path.join(DATA_HOME, 'airbnb-login-attempts.json');
+const LOGIN_HOURLY_LIMIT = 3;
+const LOGIN_DAILY_LIMIT = 10;
 const STRIP = new Set(['muxData', '_cci', '_ccv', 'previousTab', 'tzo', 'cbkp']);
 const FORCE_HEADLESS = !!process.env.HEADLESS;
 
+// Set when Airbnb rejects credentials; cleared only by process restart.
+let loginDisabledReason: string | null = null;
+
+function checkAndRecordLoginAttempt(): void {
+  const now = Date.now();
+  let attempts: number[] = [];
+  try {
+    if (fs.existsSync(LOGIN_ATTEMPTS_PATH)) {
+      attempts = (JSON.parse(fs.readFileSync(LOGIN_ATTEMPTS_PATH, 'utf8')) as { attempts?: number[] }).attempts ?? [];
+    }
+  } catch {}
+
+  const inLastHour = attempts.filter(t => t > now - 3_600_000);
+  const inLastDay = attempts.filter(t => t > now - 86_400_000);
+
+  if (inLastHour.length >= LOGIN_HOURLY_LIMIT) {
+    throw new Error(
+      `[airbnb] Auto-login rate limit: ${inLastHour.length} attempts in the last hour (max ${LOGIN_HOURLY_LIMIT}) — check credentials or wait`,
+    );
+  }
+  if (inLastDay.length >= LOGIN_DAILY_LIMIT) {
+    throw new Error(
+      `[airbnb] Auto-login rate limit: ${inLastDay.length} attempts in 24 h (max ${LOGIN_DAILY_LIMIT}) — check credentials`,
+    );
+  }
+
+  const pruned = attempts.filter(t => t > now - 86_400_000);
+  pruned.push(now);
+  fs.writeFileSync(LOGIN_ATTEMPTS_PATH, JSON.stringify({ attempts: pruned }));
+}
+
+async function performLogin(page: Page, email: string, password: string): Promise<void> {
+  if (loginDisabledReason) {
+    throw new Error(`[airbnb] Auto-login disabled for this session: ${loginDisabledReason}`);
+  }
+
+  checkAndRecordLoginAttempt();
+
+  await page.goto('https://www.airbnb.com/signup_login');
+  await page.waitForLoadState('domcontentloaded');
+
+  // Detect warm (password page shown immediately) vs cold (email input first).
+  const flow = await Promise.race([
+    page.waitForSelector('#password', { timeout: 15_000 }).then(() => 'warm' as const),
+    page.waitForSelector('#phone-or-email', { timeout: 15_000 }).then(() => 'cold' as const),
+  ]).catch(() => null);
+
+  if (!flow) {
+    loginDisabledReason = 'neither email input nor password field appeared on signup_login';
+    throw new Error(`[airbnb] ${loginDisabledReason}`);
+  }
+
+  if (flow === 'cold') {
+    console.error('[airbnb] Auto-login: cold session — filling email...');
+    await page.locator('#phone-or-email').fill(email);
+    await page.locator('button[type="submit"]').click();
+
+    const tryAnother = page.getByRole('button', { name: /try another way/i });
+    try {
+      await tryAnother.waitFor({ state: 'visible', timeout: 15_000 });
+    } catch {
+      loginDisabledReason = '"Try another way" not found — email may have been rejected';
+      throw new Error(`[airbnb] ${loginDisabledReason}`);
+    }
+    await tryAnother.click();
+
+    const passwordOption = page.locator('[data-testid="fallback-option-password"]');
+    try {
+      await passwordOption.waitFor({ state: 'visible', timeout: 10_000 });
+    } catch {
+      loginDisabledReason = 'password option not found after "Try another way"';
+      throw new Error(`[airbnb] ${loginDisabledReason}`);
+    }
+    await passwordOption.click();
+
+    try {
+      await page.locator('#password').waitFor({ state: 'visible', timeout: 10_000 });
+    } catch {
+      loginDisabledReason = 'password input not found after selecting password option';
+      throw new Error(`[airbnb] ${loginDisabledReason}`);
+    }
+  } else {
+    console.error('[airbnb] Auto-login: warm session — password page already shown.');
+  }
+
+  console.error('[airbnb] Auto-login: filling password...');
+  await page.locator('#password').fill(password);
+
+  const authDone = page.waitForResponse(
+    res => res.url().includes('/api/v2/auth/authenticate/password') && res.request().method() === 'POST',
+    { timeout: 30_000 },
+  );
+  await page.locator('button[form="login-password"][type="submit"]').click();
+
+  const authRes = await authDone;
+  if (authRes.status() !== 200) {
+    loginDisabledReason = `authenticate/password returned HTTP ${authRes.status()} — check credentials`;
+    throw new Error(`[airbnb] ${loginDisabledReason}`);
+  }
+  console.error('[airbnb] Password accepted — waiting for modal to close...');
+  await page.locator('#password').waitFor({ state: 'hidden', timeout: 10_000 });
+}
+
 export async function refresh(mode: string): Promise<CredentialsPayload> {
   const profile = profileDir('airbnb', mode);
   fs.mkdirSync(profile, { recursive: true });
 
+  const envText = fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : '';
+  const email = envText.match(/^AIRBNB_EMAIL=(\S+)/m)?.[1] ?? '';
+  const password = envText.match(/^AIRBNB_PASSWORD=(\S+)/m)?.[1] ?? '';
+
   console.error('Trying silent refresh (headless)...');
   let context = await openPersistentContext(profile, true);
   let page = context.pages()[0] ?? await context.newPage();
@@ -31,10 +142,22 @@ export async function refresh(mode: string): Promise<CredentialsPayload> {
     await context.close();
     context = await openPersistentContext(profile, false);
     page = context.pages()[0] ?? await context.newPage();
-    console.error('Not logged in — log in then navigate to your calendar (5 min timeout)...');
-    await page.goto('https://www.airbnb.com/multicalendar');
-    await page.waitForLoadState('domcontentloaded');
-    await page.waitForURL('**/multicalendar**', { timeout: 300_000 });
+
+    if (email && password) {
+      console.error('Session expired — attempting auto-login (headed)...');
+      await performLogin(page, email, password);
+      await page.goto('https://www.airbnb.com/multicalendar');
+      await page.waitForLoadState('domcontentloaded');
+      if (!page.url().includes('/multicalendar')) {
+        await context.close();
+        throw new Error('[airbnb] Auto-login failed — multicalendar not reached after password submit.');
+      }
+    } else {
+      console.error('Not logged in — log in then navigate to your calendar (5 min timeout)...');
+      await page.goto('https://www.airbnb.com/multicalendar');
+      await page.waitForLoadState('domcontentloaded');
+      await page.waitForURL('**/multicalendar**', { timeout: 300_000 });
+    }
   } else {
     console.error('Session valid — continuing silently.');
   }