Use full Chrome for interactive Reddit login
This commit is contained in:
1 parent
70d62b2da3
commit
674cd89cd7
5 files changed
+241
-3
No files matched your search
@@ -14,6 +14,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
websockify \
|
websockify \
|
||||||
fluxbox \
|
fluxbox \
|
||||||
procps \
|
procps \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
&& curl -fsSLo /tmp/google-chrome-stable_current_amd64.deb \
|
||||||
|
https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
/tmp/google-chrome-stable_current_amd64.deb \
|
||||||
|
&& rm -f /tmp/google-chrome-stable_current_amd64.deb \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ VNC_PORT="${VNC_PORT:-5900}"
|
|||||||
NOVNC_PORT="${NOVNC_PORT:-6080}"
|
NOVNC_PORT="${NOVNC_PORT:-6080}"
|
||||||
GEOMETRY="${SCREEN_GEOMETRY:-1280x800x24}"
|
GEOMETRY="${SCREEN_GEOMETRY:-1280x800x24}"
|
||||||
BROWSER_SESSION_DIR="${BROWSER_SESSION_DIR:-/app/data/browser-session}"
|
BROWSER_SESSION_DIR="${BROWSER_SESSION_DIR:-/app/data/browser-session}"
|
||||||
|
CHROME_CDP_PORT="${CHROME_CDP_PORT:-9222}"
|
||||||
|
GOOGLE_CHROME_BIN="${GOOGLE_CHROME_BIN:-google-chrome}"
|
||||||
|
export CHROME_CDP_URL="${CHROME_CDP_URL:-http://127.0.0.1:${CHROME_CDP_PORT}}"
|
||||||
export DISPLAY=":${DISPLAY_NUM}"
|
export DISPLAY=":${DISPLAY_NUM}"
|
||||||
|
|
||||||
# noVNC ships under different paths depending on the distro; find the web root once.
|
# noVNC ships under different paths depending on the distro; find the web root once.
|
||||||
@@ -104,8 +107,59 @@ websockify --web "${NOVNC_WEB_DIR}" "${NOVNC_PORT}" "localhost:${VNC_PORT}" \
|
|||||||
>/var/log/websockify.log 2>&1 &
|
>/var/log/websockify.log 2>&1 &
|
||||||
NOVNC_PID=$!
|
NOVNC_PID=$!
|
||||||
|
|
||||||
|
# Start the installed consumer Google Chrome directly. Playwright attaches only
|
||||||
|
# after Chrome is running, so the human login is not performed in a browser
|
||||||
|
# process launched with Playwright's automation arguments.
|
||||||
|
CHROME_PID=""
|
||||||
|
CHROME_READY=0
|
||||||
|
if command -v "${GOOGLE_CHROME_BIN}" >/dev/null 2>&1; then
|
||||||
|
echo "[entrypoint] starting Google Chrome for interactive login"
|
||||||
|
"${GOOGLE_CHROME_BIN}" \
|
||||||
|
--user-data-dir="${BROWSER_SESSION_DIR}" \
|
||||||
|
--remote-debugging-address=127.0.0.1 \
|
||||||
|
--remote-debugging-port="${CHROME_CDP_PORT}" \
|
||||||
|
--no-first-run \
|
||||||
|
--no-default-browser-check \
|
||||||
|
--disable-dev-shm-usage \
|
||||||
|
--no-sandbox \
|
||||||
|
--start-maximized \
|
||||||
|
about:blank \
|
||||||
|
>/var/log/google-chrome.log 2>&1 &
|
||||||
|
CHROME_PID=$!
|
||||||
|
|
||||||
|
for i in $(seq 1 100); do
|
||||||
|
if ! kill -0 "${CHROME_PID}" 2>/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if curl -fsS "${CHROME_CDP_URL}/json/version" >/dev/null 2>&1; then
|
||||||
|
echo "[entrypoint] Google Chrome is ready after ${i} checks"
|
||||||
|
CHROME_READY=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${CHROME_READY}" != "1" ]; then
|
||||||
|
echo "[entrypoint] WARNING: Google Chrome failed to start; using Playwright fallback" >&2
|
||||||
|
if [ -n "${CHROME_PID}" ]; then
|
||||||
|
kill "${CHROME_PID}" 2>/dev/null || true
|
||||||
|
wait "${CHROME_PID}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -f \
|
||||||
|
"${BROWSER_SESSION_DIR}/SingletonLock" \
|
||||||
|
"${BROWSER_SESSION_DIR}/SingletonSocket" \
|
||||||
|
"${BROWSER_SESSION_DIR}/SingletonCookie" \
|
||||||
|
2>/dev/null || true
|
||||||
|
CHROME_PID=""
|
||||||
|
unset CHROME_CDP_URL
|
||||||
|
fi
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
echo "[entrypoint] shutting down"
|
echo "[entrypoint] shutting down"
|
||||||
|
if [ -n "${CHROME_PID}" ]; then
|
||||||
|
kill "${CHROME_PID}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
kill "${NOVNC_PID}" "${X11VNC_PID}" "${XVFB_PID}" 2>/dev/null || true
|
kill "${NOVNC_PID}" "${X11VNC_PID}" "${XVFB_PID}" 2>/dev/null || true
|
||||||
wait || true
|
wait || true
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-3
@@ -4,6 +4,25 @@ import type { Logger } from '../utils/logger.js';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
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]
|
||||||
|
): Promise<BrowserContext>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultBrowserRuntime: BrowserRuntime = {
|
||||||
|
connectOverCDP: (endpoint) => chromium.connectOverCDP(endpoint),
|
||||||
|
launchPersistentContext: (userDataDir, options) =>
|
||||||
|
chromium.launchPersistentContext(userDataDir, options),
|
||||||
|
};
|
||||||
|
|
||||||
export type RedditSessionState =
|
export type RedditSessionState =
|
||||||
| 'unknown'
|
| 'unknown'
|
||||||
| 'authenticated'
|
| 'authenticated'
|
||||||
@@ -19,13 +38,16 @@ export function classifyRedditSessionError(error: unknown): Exclude<RedditSessio
|
|||||||
|
|
||||||
export class BrowserClient {
|
export class BrowserClient {
|
||||||
private context: BrowserContext | null = null;
|
private context: BrowserContext | null = null;
|
||||||
|
private connectedBrowser: ConnectedBrowser | null = null;
|
||||||
|
private usingExternalChrome = false;
|
||||||
private sessionDir: string | null = null;
|
private sessionDir: string | null = null;
|
||||||
private sessionValidityInFlight: Promise<boolean> | null = null;
|
private sessionValidityInFlight: Promise<boolean> | null = null;
|
||||||
private sessionState: RedditSessionState = 'unknown';
|
private sessionState: RedditSessionState = 'unknown';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private config: Config,
|
private config: Config,
|
||||||
private logger: Logger
|
private logger: Logger,
|
||||||
|
private browserRuntime: BrowserRuntime = defaultBrowserRuntime
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async initialize(): Promise<BrowserContext> {
|
async initialize(): Promise<BrowserContext> {
|
||||||
@@ -37,10 +59,31 @@ export class BrowserClient {
|
|||||||
fs.mkdirSync(sessionDir, { recursive: true });
|
fs.mkdirSync(sessionDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.info(`Launching browser with session dir: ${sessionDir}`);
|
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}`);
|
||||||
|
|
||||||
// Use launchPersistentContext to maintain cookies/localStorage
|
// Use launchPersistentContext to maintain cookies/localStorage
|
||||||
this.context = await chromium.launchPersistentContext(sessionDir, {
|
this.context = await this.browserRuntime.launchPersistentContext(sessionDir, {
|
||||||
headless: this.config.browser.headless,
|
headless: this.config.browser.headless,
|
||||||
viewport: { width: 1280, height: 720 },
|
viewport: { width: 1280, height: 720 },
|
||||||
userAgent: this.config.reddit.userAgent,
|
userAgent: this.config.reddit.userAgent,
|
||||||
@@ -70,6 +113,15 @@ export class BrowserClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
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) {
|
if (this.context) {
|
||||||
this.logger.info('Closing browser');
|
this.logger.info('Closing browser');
|
||||||
await this.context.close();
|
await this.context.close();
|
||||||
@@ -150,6 +202,20 @@ export class BrowserClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async login(): Promise<boolean> {
|
async login(): Promise<boolean> {
|
||||||
|
if (this.usingExternalChrome) {
|
||||||
|
try {
|
||||||
|
const page = await this.getPage();
|
||||||
|
await page.bringToFront();
|
||||||
|
await page.goto('https://www.reddit.com/login', { waitUntil: 'domcontentloaded' });
|
||||||
|
this.sessionState = 'login-required';
|
||||||
|
this.logger.warn('Interactive Reddit login required. Open the VNC page and sign in using Google Chrome.');
|
||||||
|
} catch (error) {
|
||||||
|
this.sessionState = classifyRedditSessionError(error);
|
||||||
|
this.logger.error('Failed to open Google Chrome for interactive login:', error);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.config.reddit.username || !this.config.reddit.password) {
|
if (!this.config.reddit.username || !this.config.reddit.password) {
|
||||||
this.sessionState = 'login-required';
|
this.sessionState = 'login-required';
|
||||||
this.logger.error('Login failed: Missing credentials in config/env');
|
this.logger.error('Login failed: Missing credentials in config/env');
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { BrowserContext, Page } from 'playwright';
|
||||||
|
import {
|
||||||
|
BrowserClient,
|
||||||
|
type BrowserRuntime,
|
||||||
|
type ConnectedBrowser,
|
||||||
|
} from '../src/browser/client.js';
|
||||||
|
import type { Logger } from '../src/utils/logger.js';
|
||||||
|
import { getTestConfig } from './testUtils.js';
|
||||||
|
|
||||||
|
function createLogger(): Logger {
|
||||||
|
return {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
} as unknown as Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.CHROME_CDP_URL;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BrowserClient external Chrome', () => {
|
||||||
|
it('attaches to independently launched Chrome before considering Playwright launch', async () => {
|
||||||
|
process.env.CHROME_CDP_URL = 'http://127.0.0.1:9222';
|
||||||
|
const context = { pages: () => [] } as unknown as BrowserContext;
|
||||||
|
const connectedBrowser = {
|
||||||
|
contexts: () => [context],
|
||||||
|
close: vi.fn(),
|
||||||
|
} as unknown as ConnectedBrowser;
|
||||||
|
const runtime: BrowserRuntime = {
|
||||||
|
connectOverCDP: vi.fn().mockResolvedValue(connectedBrowser),
|
||||||
|
launchPersistentContext: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = new BrowserClient(getTestConfig(), createLogger(), runtime);
|
||||||
|
const result = await client.initialize();
|
||||||
|
|
||||||
|
expect(result).toBe(context);
|
||||||
|
expect(runtime.connectOverCDP).toHaveBeenCalledWith('http://127.0.0.1:9222');
|
||||||
|
expect(runtime.launchPersistentContext).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to Playwright only when external Chrome cannot be reached', async () => {
|
||||||
|
process.env.CHROME_CDP_URL = 'http://127.0.0.1:9222';
|
||||||
|
const fallbackContext = { pages: () => [] } as unknown as BrowserContext;
|
||||||
|
const runtime: BrowserRuntime = {
|
||||||
|
connectOverCDP: vi.fn().mockRejectedValue(new Error('connection refused')),
|
||||||
|
launchPersistentContext: vi.fn().mockResolvedValue(fallbackContext),
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = new BrowserClient(getTestConfig(), createLogger(), runtime);
|
||||||
|
const result = await client.initialize();
|
||||||
|
|
||||||
|
expect(result).toBe(fallbackContext);
|
||||||
|
expect(runtime.launchPersistentContext).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the login page for a human without submitting credentials', async () => {
|
||||||
|
process.env.CHROME_CDP_URL = 'http://127.0.0.1:9222';
|
||||||
|
const page = {
|
||||||
|
goto: vi.fn().mockResolvedValue(undefined),
|
||||||
|
bringToFront: vi.fn().mockResolvedValue(undefined),
|
||||||
|
fill: vi.fn(),
|
||||||
|
} as unknown as Page;
|
||||||
|
const context = { pages: () => [page] } as unknown as BrowserContext;
|
||||||
|
const runtime: BrowserRuntime = {
|
||||||
|
connectOverCDP: vi.fn().mockResolvedValue({
|
||||||
|
contexts: () => [context],
|
||||||
|
close: vi.fn(),
|
||||||
|
}),
|
||||||
|
launchPersistentContext: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = new BrowserClient(getTestConfig({
|
||||||
|
reddit: { username: 'human', password: 'secret' },
|
||||||
|
}), createLogger(), runtime);
|
||||||
|
await client.initialize();
|
||||||
|
|
||||||
|
await expect(client.login()).resolves.toBe(false);
|
||||||
|
expect(page.bringToFront).toHaveBeenCalledOnce();
|
||||||
|
expect(page.goto).toHaveBeenCalledWith(
|
||||||
|
'https://www.reddit.com/login',
|
||||||
|
{ waitUntil: 'domcontentloaded' },
|
||||||
|
);
|
||||||
|
expect(page.fill).not.toHaveBeenCalled();
|
||||||
|
expect(client.getSessionState()).toBe('login-required');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,4 +22,25 @@ describe('VNC container entrypoint', () => {
|
|||||||
expect(script).toContain('"${BROWSER_SESSION_DIR}/SingletonCookie"');
|
expect(script).toContain('"${BROWSER_SESSION_DIR}/SingletonCookie"');
|
||||||
expect(script.indexOf('SingletonLock')).toBeLessThan(script.indexOf('launching bot'));
|
expect(script.indexOf('SingletonLock')).toBeLessThan(script.indexOf('launching bot'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('launches regular Google Chrome before the bot and exposes its local CDP endpoint', () => {
|
||||||
|
const script = fs.readFileSync(path.resolve('docker/entrypoint.sh'), 'utf8');
|
||||||
|
|
||||||
|
expect(script).toContain('google-chrome');
|
||||||
|
expect(script).toContain('--user-data-dir="${BROWSER_SESSION_DIR}"');
|
||||||
|
expect(script).toContain('--remote-debugging-port="${CHROME_CDP_PORT}"');
|
||||||
|
expect(script).toContain('export CHROME_CDP_URL=');
|
||||||
|
expect(script).not.toContain('--enable-automation');
|
||||||
|
expect(script.indexOf('starting Google Chrome')).toBeLessThan(
|
||||||
|
script.indexOf('launching bot'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('installs the stable Google Chrome package rather than Chrome for Testing', () => {
|
||||||
|
const dockerfile = fs.readFileSync(path.resolve('Dockerfile'), 'utf8');
|
||||||
|
|
||||||
|
expect(dockerfile).toContain('google-chrome-stable_current_amd64.deb');
|
||||||
|
expect(dockerfile).toContain('google-chrome-stable');
|
||||||
|
expect(dockerfile).not.toContain('chrome-for-testing');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user