feat: replace browser login with imported Reddit sessions
This commit is contained in:
1 parent
2b0545afcf
commit
c483c7b8be
76 files changed
+2048
-2828
No files matched your search
+27
-247
@@ -1,16 +1,10 @@
|
||||
import { chromium, BrowserContext, Page } from 'playwright';
|
||||
import { chromium, BrowserContext, Cookie, Page } from 'playwright';
|
||||
import type { Config } from '../config/index.js';
|
||||
import type { Logger } from '../utils/logger.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
export interface ConnectedBrowser {
|
||||
contexts(): BrowserContext[];
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface BrowserRuntime {
|
||||
connectOverCDP(endpoint: string): Promise<ConnectedBrowser>;
|
||||
launchPersistentContext(
|
||||
userDataDir: string,
|
||||
options: Parameters<typeof chromium.launchPersistentContext>[1]
|
||||
@@ -18,7 +12,6 @@ export interface BrowserRuntime {
|
||||
}
|
||||
|
||||
const defaultBrowserRuntime: BrowserRuntime = {
|
||||
connectOverCDP: (endpoint) => chromium.connectOverCDP(endpoint),
|
||||
launchPersistentContext: (userDataDir, options) =>
|
||||
chromium.launchPersistentContext(userDataDir, options),
|
||||
};
|
||||
@@ -38,9 +31,6 @@ export function classifyRedditSessionError(error: unknown): Exclude<RedditSessio
|
||||
|
||||
export class BrowserClient {
|
||||
private context: BrowserContext | null = null;
|
||||
private connectedBrowser: ConnectedBrowser | null = null;
|
||||
private usingExternalChrome = false;
|
||||
private sessionDir: string | null = null;
|
||||
private sessionValidityInFlight: Promise<boolean> | null = null;
|
||||
private sessionState: RedditSessionState = 'unknown';
|
||||
|
||||
@@ -52,39 +42,17 @@ export class BrowserClient {
|
||||
|
||||
async initialize(): Promise<BrowserContext> {
|
||||
const sessionDir = path.resolve(process.cwd(), this.config.browser.sessionDir);
|
||||
this.sessionDir = sessionDir;
|
||||
|
||||
// Ensure session directory exists
|
||||
if (!fs.existsSync(sessionDir)) {
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
}
|
||||
|
||||
const cdpEndpoint = process.env.CHROME_CDP_URL?.trim();
|
||||
if (cdpEndpoint) {
|
||||
try {
|
||||
this.logger.info(`Attaching to independently launched Google Chrome at ${cdpEndpoint}`);
|
||||
const browser = await this.browserRuntime.connectOverCDP(cdpEndpoint);
|
||||
const context = browser.contexts()[0];
|
||||
if (!context) {
|
||||
await browser.close().catch(() => undefined);
|
||||
throw new Error('External Chrome did not expose a default browser context');
|
||||
}
|
||||
|
||||
this.connectedBrowser = browser;
|
||||
this.usingExternalChrome = true;
|
||||
this.context = context;
|
||||
this.logger.info('Attached to Google Chrome; interactive login is available through VNC');
|
||||
return context;
|
||||
} catch (error) {
|
||||
this.logger.warn('Could not attach to Google Chrome; falling back to Playwright browser launch:', error);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info(`Launching fallback Playwright browser with session dir: ${sessionDir}`);
|
||||
this.logger.info(`Launching managed Playwright browser with session dir: ${sessionDir}`);
|
||||
|
||||
// Use launchPersistentContext to maintain cookies/localStorage
|
||||
this.context = await this.browserRuntime.launchPersistentContext(sessionDir, {
|
||||
headless: this.config.browser.headless,
|
||||
headless: true,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
userAgent: this.config.reddit.userAgent,
|
||||
args: [
|
||||
@@ -112,25 +80,19 @@ export class BrowserClient {
|
||||
return this.sessionState;
|
||||
}
|
||||
|
||||
async getAuthenticatedUsername(): Promise<string | null> {
|
||||
try {
|
||||
const page = await this.getPage();
|
||||
return await this.readAuthenticatedUsername(page);
|
||||
} catch {
|
||||
return null;
|
||||
/**
|
||||
* Adds a deliberately narrow cookie export to the managed persistent
|
||||
* browser. Cookie values are never logged or returned to the dashboard.
|
||||
*/
|
||||
async importRedditCookies(cookies: Cookie[]): Promise<void> {
|
||||
if (!this.context) {
|
||||
await this.initialize();
|
||||
}
|
||||
await this.context!.addCookies(cookies);
|
||||
this.sessionState = 'unknown';
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.connectedBrowser) {
|
||||
this.logger.info('Closing Google Chrome connection');
|
||||
await this.connectedBrowser.close();
|
||||
this.connectedBrowser = null;
|
||||
this.context = null;
|
||||
this.usingExternalChrome = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.context) {
|
||||
this.logger.info('Closing browser');
|
||||
await this.context.close();
|
||||
@@ -148,6 +110,15 @@ export class BrowserClient {
|
||||
return this.sessionValidityInFlight;
|
||||
}
|
||||
|
||||
/** Returns the account name from Reddit's authenticated identity endpoint. */
|
||||
async getAuthenticatedUsername(): Promise<string | null> {
|
||||
try {
|
||||
return await this.getRedditIdentity(await this.getPage());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async doSessionValidityCheck(): Promise<boolean> {
|
||||
try {
|
||||
const page = await this.getPage();
|
||||
@@ -194,17 +165,19 @@ export class BrowserClient {
|
||||
}
|
||||
|
||||
private async isLoggedInViaApi(page: Page): Promise<boolean> {
|
||||
return (await this.readAuthenticatedUsername(page)) !== null;
|
||||
return (await this.getRedditIdentity(page)) !== null;
|
||||
}
|
||||
|
||||
private async readAuthenticatedUsername(page: Page): Promise<string | null> {
|
||||
private async getRedditIdentity(page: Page): Promise<string | null> {
|
||||
try {
|
||||
return await page.evaluate(async () => {
|
||||
try {
|
||||
const res = await fetch('https://www.reddit.com/api/me.json', { credentials: 'include' });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json() as { name?: unknown };
|
||||
return typeof data.name === 'string' && data.name ? data.name : null;
|
||||
const data: unknown = await res.json();
|
||||
if (!data || typeof data !== 'object' || !('name' in data) || typeof data.name !== 'string') return null;
|
||||
const username = data.name.trim();
|
||||
return username || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -214,197 +187,4 @@ export class BrowserClient {
|
||||
}
|
||||
}
|
||||
|
||||
async login(): Promise<boolean> {
|
||||
if (this.usingExternalChrome) {
|
||||
this.sessionState = 'login-required';
|
||||
this.logger.warn('Native Reddit login required. Start it from the dashboard before signing in through VNC.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.config.reddit.username || !this.config.reddit.password) {
|
||||
this.sessionState = 'login-required';
|
||||
this.logger.error('Login failed: Missing credentials in config/env');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.info(`Attempting to log in as ${this.config.reddit.username}...`);
|
||||
const page = await this.getPage();
|
||||
|
||||
// Navigate to login page
|
||||
await page.goto('https://www.reddit.com/login', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Dismiss common consent dialogs if present
|
||||
const consentSelectors = [
|
||||
'button:has-text("Accept")',
|
||||
'button:has-text("I Accept")',
|
||||
'button:has-text("Allow all")',
|
||||
'button:has-text("OK")',
|
||||
];
|
||||
for (const selector of consentSelectors) {
|
||||
const button = page.locator(selector).first();
|
||||
if (await button.isVisible().catch(() => false)) {
|
||||
await button.click().catch(() => undefined);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for inputs
|
||||
// Reddit uses various IDs but name="username" and name="password" are reliable
|
||||
this.logger.debug('Waiting for login form...');
|
||||
|
||||
// Handle the new Reddit login UI (sometimes it's split into two steps or inside a shadow DOM/iframe,
|
||||
// but usually the specific login page is standard)
|
||||
await page.waitForSelector('input[name="username"]', { timeout: 10000 });
|
||||
await page.waitForSelector('input[name="password"]', { timeout: 10000 });
|
||||
|
||||
// Fill credentials
|
||||
await page.fill('input[name="username"]', this.config.reddit.username);
|
||||
await page.fill('input[name="password"]', this.config.reddit.password);
|
||||
|
||||
// Click login button
|
||||
// Look for a submit button or a "Log In" button
|
||||
const submitSelectors = [
|
||||
'button[type="submit"]',
|
||||
'button:has-text("Log In")',
|
||||
'button:has-text("Log in")',
|
||||
'[role="button"]:has-text("Log In")',
|
||||
'[role="button"]:has-text("Log in")',
|
||||
];
|
||||
let clicked = false;
|
||||
for (const selector of submitSelectors) {
|
||||
const button = page.locator(selector).first();
|
||||
if (await button.isVisible().catch(() => false)) {
|
||||
await button.click();
|
||||
clicked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!clicked) {
|
||||
throw new Error('Login failed: Could not find a visible submit button');
|
||||
}
|
||||
|
||||
this.logger.info('Credentials submitted. Waiting for navigation...');
|
||||
|
||||
// Wait for navigation or a non-login URL
|
||||
await Promise.race([
|
||||
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => undefined),
|
||||
page.waitForURL((url) => !url.toString().includes('/login'), { timeout: 20000 }).catch(() => undefined),
|
||||
]);
|
||||
|
||||
// Give cookies/time to settle
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const isValid = await this.isSessionValid();
|
||||
if (isValid) {
|
||||
this.sessionState = 'authenticated';
|
||||
this.logger.info('Login successful!');
|
||||
return true;
|
||||
}
|
||||
|
||||
const obstacleInfo = await this.detectLoginObstacles(page);
|
||||
if (obstacleInfo.twoFactor) {
|
||||
this.logger.error('Login requires 2FA. Please run "npm run login" locally to complete it once.');
|
||||
await this.captureLoginDebug(page, 'login-2fa');
|
||||
return false;
|
||||
}
|
||||
if (obstacleInfo.captcha) {
|
||||
this.logger.error('Login blocked by CAPTCHA. Please run "npm run login" locally to complete it once.');
|
||||
await this.captureLoginDebug(page, 'login-captcha');
|
||||
return false;
|
||||
}
|
||||
if (obstacleInfo.errorText) {
|
||||
this.logger.error(`Login failed with error: ${obstacleInfo.errorText}`);
|
||||
await this.captureLoginDebug(page, 'login-error');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for error messages
|
||||
const errorContent = obstacleInfo.errorText;
|
||||
|
||||
if (errorContent) {
|
||||
this.logger.error(`Login failed with error: ${errorContent}`);
|
||||
} else {
|
||||
this.logger.error('Login flow finished but session is invalid.');
|
||||
}
|
||||
|
||||
await this.captureLoginDebug(page, 'login-invalid-session');
|
||||
return false;
|
||||
|
||||
} catch (error) {
|
||||
this.sessionState = classifyRedditSessionError(error);
|
||||
this.logger.error('Error during automated login:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async detectLoginObstacles(page: Page): Promise<{
|
||||
twoFactor: boolean;
|
||||
captcha: boolean;
|
||||
errorText: string | null;
|
||||
}> {
|
||||
try {
|
||||
const info = await page.evaluate(() => {
|
||||
const errorCandidates = Array.from(
|
||||
document.querySelectorAll('[role="alert"], .AnimatedForm__errorMessage, [data-testid*="error"], [id*="error"]')
|
||||
)
|
||||
.map((el) => el.textContent?.trim())
|
||||
.filter((text) => text && text.length > 0) as string[];
|
||||
|
||||
const isLoginContext =
|
||||
window.location.href.includes('/login') ||
|
||||
Boolean(
|
||||
document.querySelector(
|
||||
'input[name="username"], input[name="password"], form[action*="login"], [data-testid="login-form"]'
|
||||
)
|
||||
);
|
||||
const otpInput = document.querySelector('input[name="otp"], input[autocomplete="one-time-code"], input[type="tel"][maxlength="6"]');
|
||||
const captchaElement = document.querySelector('iframe[src*="captcha"], iframe[src*="recaptcha"], [data-sitekey], #captcha, .captcha');
|
||||
|
||||
return {
|
||||
errorText: errorCandidates[0] ?? null,
|
||||
hasOtp: Boolean(otpInput) && isLoginContext,
|
||||
hasCaptcha: Boolean(captchaElement) && isLoginContext,
|
||||
};
|
||||
});
|
||||
|
||||
const frameCaptcha = page.frames().some((frame) => /captcha|recaptcha/i.test(frame.url()));
|
||||
|
||||
return {
|
||||
twoFactor: info.hasOtp,
|
||||
captcha: info.hasCaptcha || frameCaptcha,
|
||||
errorText: info.errorText,
|
||||
};
|
||||
} catch {
|
||||
return { twoFactor: false, captcha: false, errorText: null };
|
||||
}
|
||||
}
|
||||
|
||||
private async captureLoginDebug(page: Page, label: string): Promise<void> {
|
||||
try {
|
||||
const baseDir = this.sessionDir ?? path.resolve(process.cwd(), 'data');
|
||||
if (!fs.existsSync(baseDir)) {
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
}
|
||||
|
||||
const safeTimestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const screenshotPath = path.join(baseDir, `${label}-${safeTimestamp}.png`);
|
||||
const htmlPath = path.join(baseDir, `${label}-${safeTimestamp}.html`);
|
||||
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true }).catch(() => undefined);
|
||||
const html = await page.content().catch(() => '');
|
||||
if (html) {
|
||||
fs.writeFileSync(htmlPath, html, 'utf-8');
|
||||
}
|
||||
|
||||
this.logger.info(`Saved login debug artifacts to ${screenshotPath} and ${htmlPath}`);
|
||||
this.logger.info(`Login debug URL: ${page.url()}`);
|
||||
const title = await page.title().catch(() => '');
|
||||
if (title) {
|
||||
this.logger.info(`Login debug title: ${title}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to capture login debug artifacts:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user