|
@@ -1,6 +1,7 @@
|
|
|
import * as fs from 'fs';
|
|
import * as fs from 'fs';
|
|
|
import * as path from 'path';
|
|
import * as path from 'path';
|
|
|
import * as os from 'os';
|
|
import * as os from 'os';
|
|
|
|
|
+import type { Page } from 'playwright';
|
|
|
import { filterCookies, parseOutputMode } from '../shared/cookie-utils';
|
|
import { filterCookies, parseOutputMode } from '../shared/cookie-utils';
|
|
|
import { handleOutput } from '../shared/api-client';
|
|
import { handleOutput } from '../shared/api-client';
|
|
|
import { openPersistentContext, profileDir } from '../shared/browser';
|
|
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 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 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 STRIP = new Set(['muxData', '_cci', '_ccv', 'previousTab', 'tzo', 'cbkp']);
|
|
|
const FORCE_HEADLESS = !!process.env.HEADLESS;
|
|
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> {
|
|
export async function refresh(mode: string): Promise<CredentialsPayload> {
|
|
|
const profile = profileDir('airbnb', mode);
|
|
const profile = profileDir('airbnb', mode);
|
|
|
fs.mkdirSync(profile, { recursive: true });
|
|
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)...');
|
|
console.error('Trying silent refresh (headless)...');
|
|
|
let context = await openPersistentContext(profile, true);
|
|
let context = await openPersistentContext(profile, true);
|
|
|
let page = context.pages()[0] ?? await context.newPage();
|
|
let page = context.pages()[0] ?? await context.newPage();
|
|
@@ -31,10 +142,22 @@ export async function refresh(mode: string): Promise<CredentialsPayload> {
|
|
|
await context.close();
|
|
await context.close();
|
|
|
context = await openPersistentContext(profile, false);
|
|
context = await openPersistentContext(profile, false);
|
|
|
page = context.pages()[0] ?? await context.newPage();
|
|
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 {
|
|
} else {
|
|
|
console.error('Session valid — continuing silently.');
|
|
console.error('Session valid — continuing silently.');
|
|
|
}
|
|
}
|