Lightweight library to control Substack on Opera browser via Chrome DevTools Protocol (CDP).
No Puppeteer bloat. No Playwright overhead. Just direct CDP over WebSocket.
Existing browser automation libraries (Puppeteer, Playwright) launch their own browser instances. This library connects to your existing Opera session where you're already logged in.
Built specifically for Substack automation:
- Read Notes feed
- Like notes
- Post comments
- Compose new notes
- Take screenshots
- Opera browser running with remote debugging enabled
- Node.js 18+
Start Opera with the debugging flag:
/Applications/Opera.app/Contents/MacOS/Opera --remote-debugging-port=9222Or add --remote-debugging-port=9222 to your Opera shortcut.
npm install substack-opera-controlOr clone and use locally:
git clone https://github.com/yourusername/substack-opera-control.git
cd substack-opera-control
npm install# List notes from feed
node cli.js notes 10
# Take screenshot
node cli.js screenshot /tmp/screenshot.png
# Navigate
node cli.js goto https://substack.com/notes
# Get current URL
node cli.js url
# Scroll feed
node cli.js scroll 1000
# Like a note (by index)
node cli.js like 0
# Open comments on a note
node cli.js comment 0
# Post a comment (after opening comments)
node cli.js post-comment "Great insight!"
# Compose a new note (doesn't post)
node cli.js compose "Your note text here"
# Submit the composed note
node cli.js submitimport { SubstackController } from 'substack-opera-control';
const controller = new SubstackController();
async function main() {
// Connect to Opera on port 9222
await controller.connect();
// Navigate to Notes
await controller.goToNotes();
// Get visible notes
const notes = await controller.getNotes(10);
console.log(notes);
// Like the first note
await controller.likeNote(0);
// Take a screenshot
await controller.screenshot('/tmp/substack.png');
// Post a comment
await controller.openComments(0);
await controller.postComment('Great post!');
// Disconnect (keeps Opera open)
controller.disconnect();
}
main();| Method | Description |
|---|---|
connect(port) |
Connect to Opera on given port (default: 9222) |
goToNotes() |
Navigate to Substack Notes feed |
goToHome() |
Navigate to Substack Home |
getNotes(limit) |
Get visible notes from feed |
likeNote(index) |
Like a note by index |
openComments(index) |
Open comments for a note |
postComment(text) |
Post a comment (must open comments first) |
replyToNote(text) |
Reply to note on detail page (robust method) |
postNote(text) |
Compose a new note |
submitNote() |
Submit the composed note |
screenshot(path) |
Take and save screenshot |
scrollDown(pixels) |
Scroll down by pixels |
disconnect() |
Disconnect from browser (keeps Opera open) |
| Method | Description |
|---|---|
goToSettings(publication) |
Navigate to publication settings |
getNavigationItems(publication) |
Get nav bar items with visibility status |
setNavigationVisibility(pub, item, visible) |
Show/hide nav item (e.g., 'Leaderboard') |
| Method | Description |
|---|---|
goToAboutPageEditor(publication) |
Navigate to /about page editor |
goToAboutSettings(publication) |
Navigate to subscription About content |
setEditorContent(html) |
Set content in ProseMirror editor |
getEditorContent() |
Get current editor content |
clickContinue() |
Click Continue button |
configurePublishOptions(options) |
Configure audience/email settings |
clickPublish() |
Click publish button |
clickSave() |
Click save button |
| Method | Description |
|---|---|
goToSections(publication) |
Navigate to sections management |
createSection(pub, {name, description, emoji}) |
Create a new section |
getSections(publication) |
Get list of existing sections |
| Method | Description |
|---|---|
waitForElement(selector, timeout) |
Wait for element to appear |
clickButtonByText(text) |
Find and click button by text |
getButtons() |
Debug: get all buttons on page |
pageContains(text) |
Check if page contains text |
Publish content to web without sending email notifications.
| Method | Description |
|---|---|
disableEmailDelivery() |
Uncheck email delivery (call after Continue) |
publishStealth(audience) |
Publish to web only, no email ('everyone' or 'paid') |
publishAboutPageStealth(pub, html) |
Full workflow: edit + publish About page silently |
import { SubstackController } from 'substack-opera-control';
const controller = new SubstackController();
await controller.connect();
// Hide Leaderboard from navigation
await controller.setNavigationVisibility('bskiller', 'Leaderboard', false);
// Create a new section
await controller.createSection('bskiller', {
name: 'Code Drops',
emoji: '💻',
description: 'Production code you can use'
});
// Edit About page (manual steps)
await controller.goToAboutPageEditor('bskiller');
await controller.setEditorContent('<p>Hello world</p>');
await controller.clickContinue();
await controller.configurePublishOptions({ audience: 'everyone', sendEmail: false });
await controller.clickPublish();
controller.disconnect();Publish content to web only (no email notifications to subscribers):
import { SubstackController } from 'substack-opera-control';
const controller = new SubstackController();
await controller.connect();
// Option 1: Full workflow in one call
const result = await controller.publishAboutPageStealth('bskiller', `
<h1>About BSKiller</h1>
<p>The AI Intelligence Platform that calls BS on hype.</p>
`);
console.log('Published:', result.url);
// Option 2: Manual steps with stealth publish
await controller.goToAboutPageEditor('bskiller');
await controller.setEditorContent('<p>Your content here</p>');
await controller.clickContinue();
await controller.publishStealth('everyone'); // No email sent!
controller.disconnect();For custom automation needs:
import { CDPClient } from 'substack-opera-control';
const cdp = new CDPClient();
await cdp.connect(9222);
// Navigate
await cdp.navigate('https://example.com');
// Execute JavaScript
const title = await cdp.evaluate('document.title');
// Click element
await cdp.click('button.submit');
// Type text
await cdp.type('input[name="email"]', '[email protected]');
// Take screenshot
const buffer = await cdp.screenshot();MIT
BSKiller - bskiller.com