chromancer
Version:
A powerful command-line interface for automating Chrome browser using Playwright. Perfect for web scraping, automation, testing, and browser workflows.
421 lines (420 loc) ⢠15.3 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const tslib_1 = require("tslib");
const base_js_1 = require("../base.js");
const readline = tslib_1.__importStar(require("readline"));
const fs = tslib_1.__importStar(require("fs"));
const path = tslib_1.__importStar(require("path"));
const os = tslib_1.__importStar(require("os"));
class Interactive extends base_js_1.BaseCommand {
static description = 'Launch an interactive REPL with command history, tab completion, and real-time browser control - explore pages interactively with full CDP access';
static examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --port 9222',
'<%= config.bin %> <%= command.id %> --launch --profile work',
];
static flags = {
...base_js_1.BaseCommand.baseFlags,
};
rl;
historyFile;
// History is now managed by readline itself
commands = {
navigate: 'Navigate to a URL',
click: 'Click on an element',
type: 'Type text into an element',
evaluate: 'Evaluate JavaScript in the browser',
screenshot: 'Take a screenshot',
select: 'Select elements on the page',
wait: 'Wait for an element or condition',
hover: 'Hover over an element',
back: 'Go back in browser history',
forward: 'Go forward in browser history',
reload: 'Reload the current page',
url: 'Get the current URL',
title: 'Get the page title',
cookies: 'List all cookies',
viewport: 'Set or get viewport size',
login: 'Navigate and wait for login',
help: 'Show available commands',
clear: 'Clear the console',
exit: 'Exit interactive mode',
quit: 'Exit interactive mode',
};
constructor(argv, config) {
super(argv, config);
this.historyFile = path.join(os.homedir(), '.chromancer_history');
}
async run() {
const { flags } = await this.parse(Interactive);
// Connect to Chrome
await this.connectToChrome(flags.port, flags.host, flags.launch, flags.profile, flags.headless, flags.verbose, true // Always keep open in interactive mode
);
if (!this.page) {
this.error('No page available');
}
// Setup readline interface with built-in history support
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: 'chromancer> ',
completer: this.completer.bind(this),
terminal: true,
historySize: 1000, // Maximum number of history lines
removeHistoryDuplicates: true, // Automatically remove duplicate entries
});
// Load history from file after readline is created
this.loadHistory();
this.log('⨠Interactive CDP session started');
this.log('Type "help" for available commands, "exit" to quit');
this.log('');
// Ensure stdin stays open
process.stdin.resume();
// Display prompt
this.rl.prompt();
// Handle line input
this.rl.on('line', async (input) => {
const trimmedInput = input.trim();
if (trimmedInput) {
// Note: readline automatically adds to history
try {
await this.executeCommand(trimmedInput);
}
catch (error) {
this.log(`ā Error: ${error.message}`);
this.logVerbose('Command execution error', error);
}
}
this.rl?.prompt();
});
// Handle close
this.rl.on('close', () => {
this.saveHistory();
this.log('\nš Goodbye!');
process.exit(0);
});
}
completer(line) {
const completions = Object.keys(this.commands);
const hits = completions.filter((c) => c.startsWith(line));
return [hits.length ? hits : completions, line];
}
async executeCommand(input) {
const parts = input.split(/\s+/);
const command = parts[0].toLowerCase();
const args = parts.slice(1);
switch (command) {
case 'help':
this.showHelp();
break;
case 'clear':
console.clear();
break;
case 'exit':
case 'quit':
this.rl?.close();
break;
case 'navigate':
case 'goto':
if (args.length === 0) {
this.log('Usage: navigate <url>');
return;
}
await this.navigate(args[0]);
break;
case 'click':
if (args.length === 0) {
this.log('Usage: click <selector>');
return;
}
await this.click(args.join(' '));
break;
case 'type':
if (args.length < 2) {
this.log('Usage: type <selector> <text>');
return;
}
const selector = args[0];
const text = args.slice(1).join(' ');
await this.type(selector, text);
break;
case 'evaluate':
case 'eval':
if (args.length === 0) {
this.log('Usage: evaluate <javascript>');
return;
}
await this.evaluate(args.join(' '));
break;
case 'screenshot':
const filename = args[0] || `screenshot-${Date.now()}.png`;
await this.screenshot(filename);
break;
case 'select':
const selectSelector = args.join(' ') || '*';
await this.select(selectSelector);
break;
case 'wait':
if (args.length === 0) {
this.log('Usage: wait <selector>');
return;
}
await this.wait(args.join(' '));
break;
case 'hover':
if (args.length === 0) {
this.log('Usage: hover <selector>');
return;
}
await this.hover(args.join(' '));
break;
case 'back':
await this.goBack();
break;
case 'forward':
await this.goForward();
break;
case 'reload':
case 'refresh':
await this.reload();
break;
case 'url':
await this.showUrl();
break;
case 'title':
await this.showTitle();
break;
case 'cookies':
await this.showCookies();
break;
case 'viewport':
if (args.length >= 2) {
const width = parseInt(args[0]);
const height = parseInt(args[1]);
await this.setViewport(width, height);
}
else {
await this.showViewport();
}
break;
case 'login':
if (args.length === 0) {
this.log('Usage: login <url> [ready-selector]');
return;
}
const loginUrl = args[0];
const readySelector = args[1];
await this.waitForLogin(loginUrl, readySelector);
break;
default:
this.log(`Unknown command: ${command}`);
this.log('Type "help" for available commands');
}
}
showHelp() {
this.log('\nAvailable commands:');
this.log('');
Object.entries(this.commands).forEach(([cmd, desc]) => {
this.log(` ${cmd.padEnd(15)} ${desc}`);
});
this.log('');
this.log('Examples:');
this.log(' navigate https://example.com');
this.log(' click button.submit');
this.log(' type input[name="search"] hello world');
this.log(' evaluate document.title');
this.log(' screenshot output.png');
this.log(' wait .loading-complete');
this.log(' hover .dropdown-menu');
this.log(' login https://gmail.com');
this.log('');
}
async navigate(url) {
if (!this.page)
return;
// Add protocol if missing
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
this.log(`š Navigating to ${url}...`);
await this.page.goto(url, { waitUntil: 'load' });
this.log(`ā
Navigated to ${url}`);
}
async click(selector) {
if (!this.page)
return;
this.log(`š±ļø Clicking ${selector}...`);
await this.page.click(selector);
this.log(`ā
Clicked ${selector}`);
}
async type(selector, text) {
if (!this.page)
return;
this.log(`āØļø Typing into ${selector}...`);
await this.page.type(selector, text);
this.log(`ā
Typed "${text}" into ${selector}`);
}
async evaluate(code) {
if (!this.page)
return;
try {
const result = await this.page.evaluate(code);
this.log('š¤ Result:', JSON.stringify(result, null, 2));
}
catch (error) {
this.log('ā Evaluation error:', error.message);
}
}
async screenshot(filename) {
if (!this.page)
return;
// Ensure filename has proper extension
if (!filename.match(/\.(png|jpg|jpeg|webp)$/i)) {
filename += '.png';
}
this.log(`šø Taking screenshot...`);
await this.page.screenshot({ path: filename, fullPage: true });
this.log(`ā
Screenshot saved to ${filename}`);
}
async select(selector) {
if (!this.page)
return;
try {
const count = await this.page.locator(selector).count();
this.log(`š Found ${count} elements matching "${selector}"`);
if (count > 0 && count <= 10) {
// Show details for up to 10 elements
for (let i = 0; i < count; i++) {
const element = this.page.locator(selector).nth(i);
const tagName = await element.evaluate(el => el.tagName.toLowerCase());
const text = await element.textContent() || '';
const className = await element.getAttribute('class') || '';
this.log(`[${i}] <${tagName}${className ? ` class="${className}"` : ''}> ${text.substring(0, 50)}${text.length > 50 ? '...' : ''}`);
}
}
}
catch (error) {
this.log('ā Selection error:', error.message);
}
}
async wait(selector) {
if (!this.page)
return;
this.log(`ā³ Waiting for ${selector}...`);
await this.page.waitForSelector(selector, { state: 'visible' });
this.log(`ā
Element ${selector} is visible`);
}
async hover(selector) {
if (!this.page)
return;
this.log(`šÆ Hovering over ${selector}...`);
await this.page.hover(selector);
this.log(`ā
Hovered over ${selector}`);
}
async goBack() {
if (!this.page)
return;
this.log('ā¬
ļø Going back...');
await this.page.goBack();
this.log('ā
Navigated back');
}
async goForward() {
if (!this.page)
return;
this.log('ā”ļø Going forward...');
await this.page.goForward();
this.log('ā
Navigated forward');
}
async reload() {
if (!this.page)
return;
this.log('š Reloading page...');
await this.page.reload();
this.log('ā
Page reloaded');
}
async showUrl() {
if (!this.page)
return;
const url = this.page.url();
this.log('š Current URL:', url);
}
async showTitle() {
if (!this.page)
return;
const title = await this.page.title();
this.log('š Page title:', title);
}
async showCookies() {
if (!this.page)
return;
const cookies = await this.page.context().cookies();
this.log(`šŖ Found ${cookies.length} cookies:`);
cookies.forEach(cookie => {
this.log(` ${cookie.name}: ${cookie.value.substring(0, 50)}${cookie.value.length > 50 ? '...' : ''}`);
});
}
async setViewport(width, height) {
if (!this.page)
return;
await this.page.setViewportSize({ width, height });
this.log(`ā
Viewport set to ${width}x${height}`);
}
async showViewport() {
if (!this.page)
return;
const viewport = this.page.viewportSize();
if (viewport) {
this.log('š Current viewport:', `${viewport.width}x${viewport.height}`);
}
else {
this.log('No viewport set');
}
}
loadHistory() {
try {
if (fs.existsSync(this.historyFile) && this.rl) {
const data = fs.readFileSync(this.historyFile, 'utf-8');
const historyLines = data.split('\n').filter(line => line.trim());
// Add each line to readline's history
// Note: We add in reverse order because readline adds new items to the beginning
for (let i = historyLines.length - 1; i >= 0; i--) {
this.rl.history.push(historyLines[i]);
}
}
}
catch (error) {
// Ignore history load errors
}
}
saveHistory() {
try {
if (this.rl && this.rl.history) {
// Get history from readline (it's stored in reverse order)
const history = this.rl.history.slice().reverse();
// Keep last 1000 commands
const historyData = history.slice(-1000).join('\n');
fs.writeFileSync(this.historyFile, historyData);
}
}
catch (error) {
// Ignore history save errors
}
}
addToHistory(command) {
// This method is no longer needed as readline automatically manages history
// when a line is processed. The history is added by readline itself
// and duplicates are handled by the removeHistoryDuplicates option.
// Keeping this method for backward compatibility but it does nothing.
}
async finally() {
// Don't call super.finally() in interactive mode
// We want to keep the browser connection alive
if (this.rl) {
this.rl.close();
}
this.saveHistory();
// Only disconnect when the REPL is actually closing
// This will be handled by process.exit() in the close handler
}
}
exports.default = Interactive;