feat: replace browser login with imported Reddit sessions

This commit is contained in:
jjbrunton committed 2026-08-08 16:48:59 +01:00
1 parent 2b0545afcf
commit c483c7b8be
76 files changed
+2048 -2828

No files matched your search

+27 -247
View File
@@ -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);
}
}
}