This commit is contained in:
jjbrunton committed 2026-01-04 19:16:31 +00:00
commit c552956516
23 files changed
+5193

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
import { chromium, BrowserContext, 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 class BrowserClient {
private context: BrowserContext | null = null;
constructor(
private config: Config,
private logger: Logger
) {}
async initialize(): Promise<BrowserContext> {
const sessionDir = path.resolve(process.cwd(), this.config.browser.sessionDir);
// Ensure session directory exists
if (!fs.existsSync(sessionDir)) {
fs.mkdirSync(sessionDir, { recursive: true });
}
this.logger.info(`Launching browser with session dir: ${sessionDir}`);
// Use launchPersistentContext to maintain cookies/localStorage
this.context = await chromium.launchPersistentContext(sessionDir, {
headless: this.config.browser.headless,
viewport: { width: 1280, height: 720 },
userAgent: this.config.reddit.userAgent,
args: [
'--disable-blink-features=AutomationControlled',
'--no-sandbox',
],
});
return this.context;
}
async getPage(): Promise<Page> {
if (!this.context) {
await this.initialize();
}
const pages = this.context!.pages();
return pages.length > 0 ? pages[0] : await this.context!.newPage();
}
getContext(): BrowserContext | null {
return this.context;
}
async close(): Promise<void> {
if (this.context) {
this.logger.info('Closing browser');
await this.context.close();
this.context = null;
}
}
async isSessionValid(): Promise<boolean> {
try {
const page = await this.getPage();
await page.goto('https://www.reddit.com', { waitUntil: 'domcontentloaded', timeout: 30000 });
// Check for various login indicators
const isLoggedIn = await page.evaluate(() => {
// Check for user menu button
const userButton = document.querySelector('[data-testid="user-drawer-button"]');
if (userButton) return true;
// Check for login/signup buttons which indicate NOT logged in
const loginButton = document.querySelector('a[href*="login"]');
const signupButton = document.querySelector('a[href*="register"]');
// If we find login/signup prominently, we're not logged in
// But we need to be careful - some elements may exist in DOM
return !loginButton && !signupButton;
});
return isLoggedIn;
} catch (error) {
this.logger.error('Failed to check session validity:', error);
return false;
}
}
}
+184
View File
@@ -0,0 +1,184 @@
import type { Page } from 'playwright';
import type { Logger } from '../utils/logger.js';
export class RedditMessagePage {
private dryRun: boolean = false;
constructor(
private page: Page,
private logger: Logger
) {}
setDryRun(enabled: boolean): void {
this.dryRun = enabled;
if (enabled) {
this.logger.info('Dry run mode enabled - messages will be filled but not sent');
}
}
async sendMessage(recipient: string, subject: string, body: string): Promise<boolean> {
try {
this.logger.info(`Sending message to u/${recipient}`);
// Navigate to compose page
await this.page.goto('https://www.reddit.com/message/compose/', {
waitUntil: 'domcontentloaded',
timeout: 60000,
});
// Wait for the form to load - try multiple possible selectors
await this.page.waitForSelector('input[name="to"], #to, [data-testid="message-to"]', {
timeout: 30000,
});
// Wait for React to hydrate
await this.page.waitForTimeout(2000);
// Fill recipient field
const toInput = await this.page.$('input[name="to"]') || await this.page.$('#to');
if (toInput) {
await toInput.click();
await toInput.fill(recipient);
} else {
throw new Error('Could not find recipient input field');
}
// Fill subject field
const subjectInput = await this.page.$('input[name="subject"]') || await this.page.$('#subject');
if (subjectInput) {
await subjectInput.click();
await subjectInput.fill(subject);
} else {
throw new Error('Could not find subject input field');
}
// Fill message body - Reddit uses various editor implementations
// Try contenteditable div first (new Reddit)
let bodyFilled = false;
// Try markdown textarea
const textarea = await this.page.$('textarea[name="text"], textarea#text, textarea.md');
if (textarea) {
await textarea.click();
await this.typeWithDelay(body);
bodyFilled = true;
}
// Try contenteditable div (fancy editor)
if (!bodyFilled) {
const contentEditable = await this.page.$('[contenteditable="true"]');
if (contentEditable) {
await contentEditable.click();
await this.typeWithDelay(body);
bodyFilled = true;
}
}
// Try data-testid selector
if (!bodyFilled) {
const markdownInput = await this.page.$('[data-testid="markdown-input"]');
if (markdownInput) {
await markdownInput.click();
await this.typeWithDelay(body);
bodyFilled = true;
}
}
if (!bodyFilled) {
throw new Error('Could not find message body input');
}
// Wait a bit before submitting
await this.page.waitForTimeout(500);
// Dry run mode - don't actually send
if (this.dryRun) {
this.logger.info(`[DRY RUN] Message filled out for u/${recipient} - NOT sending`);
this.logger.info(`[DRY RUN] Subject: ${subject}`);
this.logger.info(`[DRY RUN] Body: ${body.slice(0, 100)}...`);
this.logger.info(`[DRY RUN] Pausing 10 seconds so you can review the form...`);
await this.page.waitForTimeout(10000);
return true;
}
// Find and click submit button
const submitButton =
(await this.page.$('button[type="submit"]')) ||
(await this.page.$('button:has-text("Send")')) ||
(await this.page.$('input[type="submit"]'));
if (!submitButton) {
throw new Error('Could not find submit button');
}
await submitButton.click();
// Wait for success indication
try {
await Promise.race([
this.page.waitForURL(/\/message\/sent/, { timeout: 15000 }),
this.page.waitForSelector('.success, [class*="success"], [class*="Success"]', { timeout: 15000 }),
]);
this.logger.info(`Message sent successfully to u/${recipient}`);
return true;
} catch {
// Check if we're on a success page or got any error
const currentUrl = this.page.url();
if (currentUrl.includes('/message/sent') || currentUrl.includes('success')) {
this.logger.info(`Message sent successfully to u/${recipient}`);
return true;
}
// Check for error messages
const errorElement = await this.page.$('.error, [class*="error"], [class*="Error"]');
if (errorElement) {
const errorText = await errorElement.textContent();
throw new Error(`Reddit error: ${errorText}`);
}
throw new Error('Message send status unclear');
}
} catch (error) {
this.logger.error(`Failed to send message to u/${recipient}:`, error);
return false;
}
}
private async typeWithDelay(text: string): Promise<void> {
// Type with human-like delays
for (const char of text) {
await this.page.keyboard.type(char, { delay: 20 + Math.random() * 30 });
}
}
async isLoggedIn(): Promise<boolean> {
try {
await this.page.goto('https://www.reddit.com', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
// Wait for page to stabilize
await this.page.waitForTimeout(2000);
// Check for user menu button (indicates logged in)
const userButton = await this.page.$('[data-testid="user-drawer-button"]');
if (userButton) {
const isVisible = await userButton.isVisible();
return isVisible;
}
// Alternative check - look for avatar or user icon
const avatar = await this.page.$('[class*="avatar"], [class*="Avatar"]');
if (avatar) {
return true;
}
return false;
} catch (error) {
this.logger.error('Failed to check login status:', error);
return false;
}
}
}