Initial
This commit is contained in:
commit
c552956516
23 files changed
+5193
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user