|
|
@@ -7,6 +7,105 @@ import { handleOutput, fetchSmsCode } from '../shared/api-client';
|
|
|
import { openPersistentContext, profileDir } from '../shared/browser';
|
|
|
import type { CredentialsPayload } from '../shared/types';
|
|
|
|
|
|
+const DATA_HOME = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
|
|
|
+const LOGIN_ATTEMPTS_PATH = path.join(DATA_HOME, 'booking-login-attempts.json');
|
|
|
+const LOGIN_HOURLY_LIMIT = 3;
|
|
|
+const LOGIN_DAILY_LIMIT = 10;
|
|
|
+
|
|
|
+// Set when Booking.com rejects credentials; cleared only by process restart.
|
|
|
+// Prevents repeated bad-credential attempts from triggering account lockout.
|
|
|
+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(
|
|
|
+ `[booking] 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(
|
|
|
+ `[booking] Auto-login rate limit: ${inLastDay.length} attempts in 24 h (max ${LOGIN_DAILY_LIMIT}) — check credentials`,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // Record before making any network calls so crashes/timeouts still count against the limit.
|
|
|
+ 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(`[booking] Auto-login disabled for this session: ${loginDisabledReason}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ checkAndRecordLoginAttempt();
|
|
|
+
|
|
|
+ // Dismiss cookie consent banner if present.
|
|
|
+ try {
|
|
|
+ await page.getByRole('button', { name: /accept|agree|got it|ok/i }).first().click({ timeout: 3_000 });
|
|
|
+ console.error('[booking] Dismissed cookie consent.');
|
|
|
+ } catch { /* no banner */ }
|
|
|
+
|
|
|
+ // Use Locators (not ElementHandles) — they re-query the DOM on every action
|
|
|
+ // and stay live through SPA re-renders.
|
|
|
+ const emailLocator = page.locator('input[type="email"], input[name="username"], input[autocomplete="username"]').first();
|
|
|
+ try {
|
|
|
+ await emailLocator.waitFor({ timeout: 15_000 });
|
|
|
+ } catch {
|
|
|
+ loginDisabledReason = 'email input not found on sign-in page';
|
|
|
+ throw new Error(`[booking] ${loginDisabledReason}`);
|
|
|
+ }
|
|
|
+ console.error('[booking] Auto-login: filling email...');
|
|
|
+ await emailLocator.click();
|
|
|
+ await emailLocator.pressSequentially(email, { delay: 50 });
|
|
|
+ await page.locator('button[type="submit"]').click();
|
|
|
+
|
|
|
+ const passwordLocator = page.locator('input[autocomplete="current-password"]:not([aria-hidden="true"])').first();
|
|
|
+ try {
|
|
|
+ await passwordLocator.waitFor({ state: 'visible', timeout: 15_000 });
|
|
|
+ } catch {
|
|
|
+ loginDisabledReason = 'password input not found — email may have been rejected';
|
|
|
+ throw new Error(`[booking] ${loginDisabledReason}`);
|
|
|
+ }
|
|
|
+ console.error('[booking] Auto-login: filling password...');
|
|
|
+ await passwordLocator.click();
|
|
|
+ await passwordLocator.fill(password);
|
|
|
+ await page.locator('button[type="submit"]').click();
|
|
|
+ console.error(`[booking] Password submitted — at: ${page.url().slice(0, 80)}`);
|
|
|
+}
|
|
|
+
|
|
|
+function needsChallenge(url: string): boolean {
|
|
|
+ return url.includes('auth-assurance') || url.includes('account-recovery');
|
|
|
+}
|
|
|
+
|
|
|
+async function handlePostLoginChallenge(page: Page, masked: string): Promise<void> {
|
|
|
+ const url = page.url();
|
|
|
+ if (url.includes('account-recovery')) {
|
|
|
+ console.error('[booking] New-device verification required (account-recovery)...');
|
|
|
+ // Choose SMS option if presented with a choice.
|
|
|
+ try {
|
|
|
+ await page.getByText(/sms|text message|phone/i).first().click({ timeout: 5_000 });
|
|
|
+ await page.waitForLoadState('domcontentloaded');
|
|
|
+ } catch {
|
|
|
+ // Already on code-entry page.
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // Both auth-assurance and account-recovery end up at a PIN entry screen.
|
|
|
+ await handleAuthAssurance(page, masked);
|
|
|
+}
|
|
|
+
|
|
|
async function handleAuthAssurance(page: Page, masked: string): Promise<void> {
|
|
|
console.error(`[booking] 2FA required (${masked || 'unknown number'}) — attempting SMS automation`);
|
|
|
|
|
|
@@ -41,7 +140,6 @@ async function handleAuthAssurance(page: Page, masked: string): Promise<void> {
|
|
|
await page.keyboard.press('Enter');
|
|
|
}
|
|
|
|
|
|
-const DATA_HOME = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
|
|
|
const ENV_PATH = path.join(DATA_HOME, 'booking.env');
|
|
|
const ADMIN_BASE = 'admin.booking.com';
|
|
|
const STRIP = new Set(['OptanonConsent', 'pcm_consent', 'uz']);
|
|
|
@@ -54,6 +152,8 @@ export async function refresh(mode: string): Promise<CredentialsPayload> {
|
|
|
const envText = fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : '';
|
|
|
const hotelId = envText.match(/^BOOKING_HOTEL_ID=(\S+)/m)?.[1] ?? '';
|
|
|
const ses = envText.match(/^BOOKING_SESSION=(\S+)/m)?.[1] ?? '';
|
|
|
+ const email = envText.match(/^BOOKING_EMAIL=(\S+)/m)?.[1] ?? '';
|
|
|
+ const password = envText.match(/^BOOKING_PASSWORD=(\S+)/m)?.[1] ?? '';
|
|
|
const monthlyUrl = `https://${ADMIN_BASE}/hotel/hoteladmin/extranet_ng/manage/calendar/monthly.html?hotel_id=${hotelId}&lang=en&ses=${ses}&view_mode=MONTHLY`;
|
|
|
|
|
|
let capturedCsrf: string | null = null;
|
|
|
@@ -90,15 +190,27 @@ export async function refresh(mode: string): Promise<CredentialsPayload> {
|
|
|
await page.waitForLoadState('domcontentloaded');
|
|
|
|
|
|
if (!page.url().includes('ses=')) {
|
|
|
- if (FORCE_HEADLESS) {
|
|
|
+ if (email && password && page.url().includes('account.booking.com/sign-in')) {
|
|
|
+ console.error('Session expired — attempting auto-login (headless)...');
|
|
|
+ await performLogin(page, email, password);
|
|
|
+ await page.waitForURL(
|
|
|
+ url => url.toString().includes('ses=') || needsChallenge(url.toString()),
|
|
|
+ { timeout: 60_000 },
|
|
|
+ );
|
|
|
+ if (needsChallenge(page.url())) {
|
|
|
+ await handlePostLoginChallenge(page, smsMasked);
|
|
|
+ await page.waitForURL('**ses=**', { timeout: 120_000 });
|
|
|
+ }
|
|
|
+ } else if (FORCE_HEADLESS) {
|
|
|
await context.close();
|
|
|
throw new Error('Session expired — re-run without HEADLESS=1 to re-authenticate.');
|
|
|
+ } else {
|
|
|
+ console.error('Profile session expired — switching to headed browser for login...');
|
|
|
+ await context.close();
|
|
|
+ headless = false;
|
|
|
+ context = await openCtx(false);
|
|
|
+ page = context.pages()[0] ?? await context.newPage();
|
|
|
}
|
|
|
- console.error('Profile session expired — switching to headed browser for login...');
|
|
|
- await context.close();
|
|
|
- headless = false;
|
|
|
- context = await openCtx(false);
|
|
|
- page = context.pages()[0] ?? await context.newPage();
|
|
|
} else {
|
|
|
console.error('Session valid — continuing silently.');
|
|
|
}
|
|
|
@@ -110,14 +222,27 @@ export async function refresh(mode: string): Promise<CredentialsPayload> {
|
|
|
await page.waitForLoadState('domcontentloaded');
|
|
|
|
|
|
if (!page.url().includes('ses=')) {
|
|
|
- console.error('Not logged in — log in and the script will continue automatically (5 min timeout)...');
|
|
|
- await page.waitForURL(
|
|
|
- url => url.toString().includes('ses=') || url.toString().includes('auth-assurance'),
|
|
|
- { timeout: 300_000 },
|
|
|
- );
|
|
|
- if (!page.url().includes('ses=')) {
|
|
|
- await handleAuthAssurance(page, smsMasked);
|
|
|
- await page.waitForURL('**ses=**', { timeout: 120_000 });
|
|
|
+ if (email && password && page.url().includes('account.booking.com/sign-in')) {
|
|
|
+ console.error('Session expired — attempting auto-login (headed)...');
|
|
|
+ await performLogin(page, email, password);
|
|
|
+ await page.waitForURL(
|
|
|
+ url => url.toString().includes('ses=') || needsChallenge(url.toString()),
|
|
|
+ { timeout: 60_000 },
|
|
|
+ );
|
|
|
+ if (needsChallenge(page.url())) {
|
|
|
+ await handlePostLoginChallenge(page, smsMasked);
|
|
|
+ await page.waitForURL('**ses=**', { timeout: 120_000 });
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ console.error('Not logged in — log in and the script will continue automatically (5 min timeout)...');
|
|
|
+ await page.waitForURL(
|
|
|
+ url => url.toString().includes('ses=') || url.toString().includes('auth-assurance'),
|
|
|
+ { timeout: 300_000 },
|
|
|
+ );
|
|
|
+ if (!page.url().includes('ses=')) {
|
|
|
+ await handleAuthAssurance(page, smsMasked);
|
|
|
+ await page.waitForURL('**ses=**', { timeout: 120_000 });
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
}
|