소스 검색

service_api: add --user flag for human-assisted credential refresh

Opens a headed browser without driving the UI — useful when the login
sequence changes or a captcha appears. All event listeners (CSRF, SMS
masked, cookie capture, messaging 2FA prime) run normally after manual
login completes. Also print failure reasons in refresh-all so errors
from rate limits or other failures are visible instead of silent.
ZiB 4 달 전
부모
커밋
214776ca47

+ 43 - 30
room_cal/service_api/typescript/airbnb/refresh-cookies.ts

@@ -2,7 +2,7 @@ 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 type { Page } from 'playwright';
-import { filterCookies, parseOutputMode } from '../shared/cookie-utils';
+import { filterCookies, parseOutputMode, parseUserDriven } 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';
 import type { CredentialsPayload } from '../shared/types';
 import type { CredentialsPayload } from '../shared/types';
@@ -118,7 +118,7 @@ async function performLogin(page: Page, email: string, password: string): Promis
   await page.locator('#password').waitFor({ state: 'hidden', timeout: 10_000 });
   await page.locator('#password').waitFor({ state: 'hidden', timeout: 10_000 });
 }
 }
 
 
-export async function refresh(mode: string): Promise<CredentialsPayload> {
+export async function refresh(mode: string, userDriven = false): Promise<CredentialsPayload> {
   const profile = profileDir('airbnb', mode);
   const profile = profileDir('airbnb', mode);
   fs.mkdirSync(profile, { recursive: true });
   fs.mkdirSync(profile, { recursive: true });
 
 
@@ -126,40 +126,52 @@ export async function refresh(mode: string): Promise<CredentialsPayload> {
   const email = envText.match(/^AIRBNB_EMAIL=(\S+)/m)?.[1] ?? '';
   const email = envText.match(/^AIRBNB_EMAIL=(\S+)/m)?.[1] ?? '';
   const password = envText.match(/^AIRBNB_PASSWORD=(\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 context = await openPersistentContext(profile, !userDriven);
   let page = context.pages()[0] ?? await context.newPage();
   let page = context.pages()[0] ?? await context.newPage();
 
 
-  await page.goto('https://www.airbnb.com/multicalendar');
-  await page.waitForLoadState('domcontentloaded');
-
-  if (!page.url().includes('/multicalendar')) {
-    if (FORCE_HEADLESS) {
-      await context.close();
-      throw new Error('Not logged in — re-run without HEADLESS=1 to authenticate.');
+  if (userDriven) {
+    console.error('[airbnb] User-driven mode — not touching the UI.');
+    await page.goto('https://www.airbnb.com/multicalendar');
+    await page.waitForLoadState('domcontentloaded');
+    if (!page.url().includes('/multicalendar')) {
+      console.error('[airbnb] Not logged in — log in then navigate to your calendar (5 min timeout)...');
+      await page.waitForURL('**/multicalendar**', { timeout: 300_000 });
+    } else {
+      console.error('[airbnb] Session valid.');
     }
     }
-    console.error('Session expired — switching to headed browser for login...');
-    await context.close();
-    context = await openPersistentContext(profile, false);
-    page = context.pages()[0] ?? await context.newPage();
-
-    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')) {
+  } else {
+    console.error('Trying silent refresh (headless)...');
+    await page.goto('https://www.airbnb.com/multicalendar');
+    await page.waitForLoadState('domcontentloaded');
+
+    if (!page.url().includes('/multicalendar')) {
+      if (FORCE_HEADLESS) {
         await context.close();
         await context.close();
-        throw new Error('[airbnb] Auto-login failed — multicalendar not reached after password submit.');
+        throw new Error('Not logged in — re-run without HEADLESS=1 to authenticate.');
+      }
+      console.error('Session expired — switching to headed browser for login...');
+      await context.close();
+      context = await openPersistentContext(profile, false);
+      page = context.pages()[0] ?? await context.newPage();
+
+      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('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 });
+      console.error('Session valid — continuing silently.');
     }
     }
-  } else {
-    console.error('Session valid — continuing silently.');
   }
   }
 
 
   console.error('Capturing cookies...');
   console.error('Capturing cookies...');
@@ -175,7 +187,8 @@ export async function refresh(mode: string): Promise<CredentialsPayload> {
 
 
 async function main(): Promise<void> {
 async function main(): Promise<void> {
   const mode = parseOutputMode(process.argv);
   const mode = parseOutputMode(process.argv);
-  const credentials = await refresh(mode);
+  const userDriven = parseUserDriven(process.argv);
+  const credentials = await refresh(mode, userDriven);
   await handleOutput('airbnb', credentials, mode, ENV_PATH);
   await handleOutput('airbnb', credentials, mode, ENV_PATH);
 }
 }
 
 

+ 8 - 6
room_cal/service_api/typescript/booking/refresh-cookies.ts

@@ -2,7 +2,7 @@ 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 { BrowserContext, Page } from 'playwright';
 import type { BrowserContext, Page } from 'playwright';
-import { filterCookies, parseOutputMode } from '../shared/cookie-utils';
+import { filterCookies, parseOutputMode, parseUserDriven } from '../shared/cookie-utils';
 import { handleOutput, fetchSmsCode, clearSmsCode } from '../shared/api-client';
 import { handleOutput, fetchSmsCode, clearSmsCode } from '../shared/api-client';
 import { openPersistentContext, profileDir } from '../shared/browser';
 import { openPersistentContext, profileDir } from '../shared/browser';
 import type { CredentialsPayload } from '../shared/types';
 import type { CredentialsPayload } from '../shared/types';
@@ -240,7 +240,7 @@ const STRIP = new Set(['OptanonConsent', 'pcm_consent', 'uz']);
 const FORCE_HEADLESS = !!process.env.HEADLESS;
 const FORCE_HEADLESS = !!process.env.HEADLESS;
 const SKIP_CHAT_2FA = !!process.env.SKIP_CHAT_2FA;
 const SKIP_CHAT_2FA = !!process.env.SKIP_CHAT_2FA;
 
 
-export async function refresh(mode: string): Promise<CredentialsPayload> {
+export async function refresh(mode: string, userDriven = false): Promise<CredentialsPayload> {
   const profile = profileDir('booking', mode);
   const profile = profileDir('booking', mode);
   fs.mkdirSync(profile, { recursive: true });
   fs.mkdirSync(profile, { recursive: true });
 
 
@@ -275,7 +275,7 @@ export async function refresh(mode: string): Promise<CredentialsPayload> {
     return ctx;
     return ctx;
   };
   };
 
 
-  let headless = FORCE_HEADLESS || !!(hotelId && ses);
+  let headless = !userDriven && (FORCE_HEADLESS || !!(hotelId && ses));
   let context = await openCtx(headless);
   let context = await openCtx(headless);
   let page = context.pages()[0] ?? await context.newPage();
   let page = context.pages()[0] ?? await context.newPage();
 
 
@@ -300,12 +300,13 @@ export async function refresh(mode: string): Promise<CredentialsPayload> {
   }
   }
 
 
   if (!headless) {
   if (!headless) {
-    console.error('Opening Booking.com extranet...');
+    if (userDriven) console.error('[booking] User-driven mode — not touching the UI.');
+    else console.error('Opening Booking.com extranet...');
     await page.goto(hotelId && ses ? monthlyUrl : `https://${ADMIN_BASE}/`);
     await page.goto(hotelId && ses ? monthlyUrl : `https://${ADMIN_BASE}/`);
     await page.waitForLoadState('domcontentloaded');
     await page.waitForLoadState('domcontentloaded');
 
 
     if (!page.url().includes('ses=')) {
     if (!page.url().includes('ses=')) {
-      if (email && password && page.url().includes('account.booking.com/sign-in')) {
+      if (!userDriven && email && password && page.url().includes('account.booking.com/sign-in')) {
         console.error('Session expired — attempting auto-login (headed)...');
         console.error('Session expired — attempting auto-login (headed)...');
         await performLogin(page, email, password);
         await performLogin(page, email, password);
         await page.waitForURL(
         await page.waitForURL(
@@ -374,7 +375,8 @@ export async function refresh(mode: string): Promise<CredentialsPayload> {
 
 
 async function main(): Promise<void> {
 async function main(): Promise<void> {
   const mode = parseOutputMode(process.argv);
   const mode = parseOutputMode(process.argv);
-  const credentials = await refresh(mode);
+  const userDriven = parseUserDriven(process.argv);
+  const credentials = await refresh(mode, userDriven);
   await handleOutput('booking', credentials, mode, ENV_PATH);
   await handleOutput('booking', credentials, mode, ENV_PATH);
 }
 }
 
 

+ 8 - 4
room_cal/service_api/typescript/scripts/refresh-all.ts

@@ -1,13 +1,14 @@
 import { refresh as refreshAirbnb } from '../airbnb/refresh-cookies';
 import { refresh as refreshAirbnb } from '../airbnb/refresh-cookies';
 import { refresh as refreshBooking } from '../booking/refresh-cookies';
 import { refresh as refreshBooking } from '../booking/refresh-cookies';
 import { handleOutput } from '../shared/api-client';
 import { handleOutput } from '../shared/api-client';
-import { parseOutputMode } from '../shared/cookie-utils';
+import { parseOutputMode, parseUserDriven } from '../shared/cookie-utils';
 import type { CredentialsPayload } from '../shared/types';
 import type { CredentialsPayload } from '../shared/types';
 import * as path from 'path';
 import * as path from 'path';
 import * as os from 'os';
 import * as os from 'os';
 
 
 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 mode = parseOutputMode(process.argv);
 const mode = parseOutputMode(process.argv);
+const userDriven = parseUserDriven(process.argv);
 
 
 async function refreshService(
 async function refreshService(
   name: string,
   name: string,
@@ -20,13 +21,16 @@ async function refreshService(
 
 
 async function main(): Promise<void> {
 async function main(): Promise<void> {
   const results = await Promise.allSettled([
   const results = await Promise.allSettled([
-    refreshService('airbnb', () => refreshAirbnb(mode), path.join(DATA_HOME, 'airbnb.env')),
-    refreshService('booking', () => refreshBooking(mode), path.join(DATA_HOME, 'booking.env')),
+    refreshService('airbnb', () => refreshAirbnb(mode, userDriven), path.join(DATA_HOME, 'airbnb.env')),
+    refreshService('booking', () => refreshBooking(mode, userDriven), path.join(DATA_HOME, 'booking.env')),
   ]);
   ]);
   const failures = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected');
   const failures = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected');
   if (mode !== 'stdout') {
   if (mode !== 'stdout') {
     if (failures.length === 0) console.error('\nAll credentials refreshed.');
     if (failures.length === 0) console.error('\nAll credentials refreshed.');
-    else console.error(`\n${failures.length} service(s) failed to refresh.`);
+    else {
+      for (const f of failures) console.error(`[error] ${(f.reason as Error)?.message ?? f.reason}`);
+      console.error(`\n${failures.length} service(s) failed to refresh.`);
+    }
   }
   }
   if (failures.length > 0) process.exit(1);
   if (failures.length > 0) process.exit(1);
 }
 }

+ 4 - 0
room_cal/service_api/typescript/shared/cookie-utils.ts

@@ -22,3 +22,7 @@ export function parseOutputMode(argv: string[]): OutputMode {
   if (mode === 'stdout' || mode === 'file' || mode === 'api') return mode;
   if (mode === 'stdout' || mode === 'file' || mode === 'api') return mode;
   return 'file';
   return 'file';
 }
 }
+
+export function parseUserDriven(argv: string[]): boolean {
+  return argv.includes('--user');
+}