Initial commit: config-driven Twitter bot with multi-prompt support
- Kernel.sh stealth browser for X login and posting - Claude Sonnet 4.6 for tweet generation - prompts.json for configurable prompts with frequency/scheduling - Per-prompt history tracking to avoid repetition - Scheduler with random time window support Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.env
|
||||
.state.json
|
||||
history.json
|
||||
history-*.json
|
||||
bot.log
|
||||
276
bot.js
Normal file
276
bot.js
Normal file
@@ -0,0 +1,276 @@
|
||||
import "dotenv/config";
|
||||
import Kernel from "@onkernel/sdk";
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PROMPTS_FILE = join(__dirname, "prompts.json");
|
||||
|
||||
function loadPrompts() {
|
||||
return JSON.parse(readFileSync(PROMPTS_FILE, "utf-8"));
|
||||
}
|
||||
|
||||
function historyPath(name) {
|
||||
return join(__dirname, `history-${name}.json`);
|
||||
}
|
||||
|
||||
function loadHistory(name) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(historyPath(name), "utf-8"));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveHistory(name, history) {
|
||||
writeFileSync(historyPath(name), JSON.stringify(history, null, 2));
|
||||
}
|
||||
|
||||
function shouldRun(promptConfig) {
|
||||
const history = loadHistory(promptConfig.name);
|
||||
const now = new Date();
|
||||
|
||||
if (promptConfig.frequency === "daily") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (promptConfig.frequency === "2x_week") {
|
||||
// Max 2 per week
|
||||
const dayOfWeek = now.getDay();
|
||||
const mondayOffset = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||
const monday = new Date(now);
|
||||
monday.setDate(monday.getDate() - mondayOffset);
|
||||
monday.setHours(0, 0, 0, 0);
|
||||
|
||||
const thisWeekPosts = history.filter((h) => new Date(h.date) >= monday);
|
||||
if (thisWeekPosts.length >= 2) return false;
|
||||
|
||||
// Min 2 days between posts
|
||||
if (history.length > 0) {
|
||||
const lastPost = new Date(history[history.length - 1].date);
|
||||
const daysSinceLast = (now - lastPost) / (1000 * 60 * 60 * 24);
|
||||
if (daysSinceLast < 2) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildPrompt(promptConfig, history) {
|
||||
let prompt = promptConfig.prompt;
|
||||
prompt += "\n\nGenerate 1 tweet. Keep it under 280 characters.\n\nDo not explain the tweet. Do not add options. Just output the tweet.";
|
||||
|
||||
if (history.length > 0) {
|
||||
const recent = history.slice(-20).map((h) => h.tweet).join("\n---\n");
|
||||
prompt += "\n\nHere are previous tweets. Do NOT repeat or closely resemble any of these:\n\n" + recent;
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
async function generateTweet(promptConfig, history) {
|
||||
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": process.env.ANTHROPIC,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "claude-sonnet-4-6",
|
||||
max_tokens: 280,
|
||||
messages: [{ role: "user", content: buildPrompt(promptConfig, history) }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Anthropic API error: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return data.content[0].text.trim();
|
||||
}
|
||||
|
||||
async function exec(kernel, sessionId, code, timeout = 30) {
|
||||
const result = await kernel.browsers.playwright.execute(sessionId, {
|
||||
code,
|
||||
timeout_sec: timeout,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function readPage(kernel, sessionId) {
|
||||
const result = await exec(kernel, sessionId, `
|
||||
await page.waitForTimeout(2000);
|
||||
const inputs = await page.locator('input').all();
|
||||
const info = [];
|
||||
for (const inp of inputs) {
|
||||
const type = await inp.getAttribute('type');
|
||||
const name = await inp.getAttribute('name');
|
||||
info.push({ type, name });
|
||||
}
|
||||
const texts = await page.locator('h1, h2, span').allTextContents();
|
||||
return { url: page.url(), inputs: info, texts: texts.filter(t => t.trim()).slice(0, 20) };
|
||||
`);
|
||||
if (!result.success) throw new Error("readPage failed: " + result.error);
|
||||
return result.result;
|
||||
}
|
||||
|
||||
async function login(kernel, sessionId) {
|
||||
const xAcct = JSON.stringify(process.env.X_ACCT);
|
||||
const xPw = JSON.stringify(process.env.X_PW);
|
||||
const xEmail = JSON.stringify(process.env.X_EMAIL);
|
||||
const xPhone = JSON.stringify(process.env.X_PHONE);
|
||||
|
||||
await exec(kernel, sessionId, `
|
||||
await page.goto('https://x.com/login', { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(3000);
|
||||
`);
|
||||
|
||||
const r1 = await exec(kernel, sessionId, `
|
||||
const input = page.locator('input[autocomplete="username"]');
|
||||
await input.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await input.click();
|
||||
await input.fill(${xAcct});
|
||||
await page.waitForTimeout(500);
|
||||
const nextBtn = page.locator('[role="button"]').filter({ hasText: 'Next' });
|
||||
await nextBtn.click();
|
||||
await page.waitForTimeout(5000);
|
||||
`, 30);
|
||||
console.log("Username:", r1.success ? "ok" : r1.error);
|
||||
if (!r1.success) throw new Error("Username failed: " + r1.error);
|
||||
|
||||
let page = await readPage(kernel, sessionId);
|
||||
console.log("After username:", JSON.stringify(page, null, 2));
|
||||
|
||||
const hasPassword = page.inputs.some((i) => i.type === "password");
|
||||
const pageText = page.texts.join(" ").toLowerCase();
|
||||
|
||||
if (!hasPassword && (pageText.includes("unusual login") || pageText.includes("verify") || pageText.includes("confirm"))) {
|
||||
let verifyValue;
|
||||
if (pageText.includes("phone number or email")) {
|
||||
verifyValue = xPhone;
|
||||
} else if (pageText.includes("email")) {
|
||||
verifyValue = xEmail;
|
||||
} else {
|
||||
verifyValue = xPhone;
|
||||
}
|
||||
console.log("Verification required");
|
||||
|
||||
const rv = await exec(kernel, sessionId, `
|
||||
const input = page.locator('input[type="text"]');
|
||||
await input.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await input.click();
|
||||
await input.fill(${verifyValue});
|
||||
await page.waitForTimeout(500);
|
||||
const nextBtn = page.locator('[role="button"]').filter({ hasText: 'Next' });
|
||||
await nextBtn.click();
|
||||
await page.waitForTimeout(5000);
|
||||
`, 30);
|
||||
if (!rv.success) throw new Error("Verification failed: " + rv.error);
|
||||
|
||||
page = await readPage(kernel, sessionId);
|
||||
}
|
||||
|
||||
const r2 = await exec(kernel, sessionId, `
|
||||
const input = page.locator('input[type="password"]');
|
||||
await input.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await input.click();
|
||||
await input.fill(${xPw});
|
||||
await page.waitForTimeout(500);
|
||||
const loginBtn = page.getByTestId('LoginForm_Login_Button');
|
||||
await loginBtn.click();
|
||||
await page.waitForTimeout(5000);
|
||||
`, 30);
|
||||
if (!r2.success) throw new Error("Password failed: " + r2.error);
|
||||
|
||||
page = await readPage(kernel, sessionId);
|
||||
console.log("Logged in:", page.url);
|
||||
return page.url;
|
||||
}
|
||||
|
||||
async function postTweet(kernel, sessionId, tweetText) {
|
||||
const composeResult = await exec(kernel, sessionId, `
|
||||
const composeBtn = page.getByTestId('SideNav_NewTweet_Button');
|
||||
await composeBtn.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await composeBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
const editor = page.getByRole('textbox');
|
||||
await editor.waitFor({ state: 'visible', timeout: 10000 });
|
||||
return 'ready';
|
||||
`, 30);
|
||||
if (!composeResult.success) throw new Error("Compose failed: " + composeResult.error);
|
||||
|
||||
const tweetStr = JSON.stringify(tweetText);
|
||||
const post = await exec(kernel, sessionId, `
|
||||
const editor = page.getByRole('textbox');
|
||||
await editor.click();
|
||||
await page.keyboard.type(${tweetStr}, { delay: 30 });
|
||||
await page.waitForTimeout(1000);
|
||||
const postButton = page.getByTestId('tweetButton');
|
||||
await postButton.click();
|
||||
await page.waitForTimeout(5000);
|
||||
return { url: page.url() };
|
||||
`, 120);
|
||||
|
||||
if (!post.success) throw new Error(post.error || "Failed to post");
|
||||
return post;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const promptName = process.argv[2];
|
||||
if (!promptName) {
|
||||
console.error("Usage: node bot.js <prompt-name>");
|
||||
console.error("Available:", loadPrompts().map((p) => p.name).join(", "));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const prompts = loadPrompts();
|
||||
const promptConfig = prompts.find((p) => p.name === promptName);
|
||||
if (!promptConfig) {
|
||||
console.error(`Prompt "${promptName}" not found. Available:`, prompts.map((p) => p.name).join(", "));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!shouldRun(promptConfig)) {
|
||||
console.log(`"${promptName}" already hit its limit this week. Skipping.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const kernel = new Kernel({ apiKey: process.env.KERNEL });
|
||||
const browser = await kernel.browsers.create({ stealth: true });
|
||||
const sessionId = browser.session_id;
|
||||
console.log(`[${promptName}] Session: ${sessionId}`);
|
||||
|
||||
try {
|
||||
const url = await login(kernel, sessionId);
|
||||
if (!url.includes("x.com/home")) {
|
||||
throw new Error("Login didn't reach home page: " + url);
|
||||
}
|
||||
|
||||
const history = loadHistory(promptName);
|
||||
const rawTweet = await generateTweet(promptConfig, history);
|
||||
const tweet = promptConfig.link ? rawTweet + "\n\n" + promptConfig.link : rawTweet;
|
||||
console.log(`[${promptName}] Tweet: "${tweet}"`);
|
||||
|
||||
await postTweet(kernel, sessionId, tweet);
|
||||
console.log(`[${promptName}] Posted!`);
|
||||
|
||||
history.push({ tweet: rawTweet, date: new Date().toISOString() });
|
||||
saveHistory(promptName, history);
|
||||
console.log(`[${promptName}] Total: ${history.length}`);
|
||||
} finally {
|
||||
try {
|
||||
await kernel.browsers.delete(sessionId);
|
||||
console.log("Session closed.");
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Failed:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
34
package-lock.json
generated
Normal file
34
package-lock.json
generated
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "twitter-bot",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "twitter-bot",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@onkernel/sdk": "latest",
|
||||
"dotenv": "^17.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@onkernel/sdk": {
|
||||
"version": "0.42.1",
|
||||
"resolved": "https://registry.npmjs.org/@onkernel/sdk/-/sdk-0.42.1.tgz",
|
||||
"integrity": "sha512-Xfy1RITna1RSHppfMZAiJH7PNqasty5UIaOSCvXnHxZdfZ4S7KOEwRIMQ7AGk5Oib5/bJxMRYMaQLT3SaBivTA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
||||
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
package.json
Normal file
14
package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "twitter-bot",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"auth": "node auth.js",
|
||||
"post": "node bot.js",
|
||||
"start": "node scheduler.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@onkernel/sdk": "latest",
|
||||
"dotenv": "^17.3.1"
|
||||
}
|
||||
}
|
||||
18
prompts.json
Normal file
18
prompts.json
Normal file
@@ -0,0 +1,18 @@
|
||||
[
|
||||
{
|
||||
"name": "tease",
|
||||
"prompt": "You are writing a tweet for a woman who is quietly building a creative portfolio and wants people to visit her site out of curiosity.\n\nTone: casual, understated, real. Like she's just talking — not performing, not selling, not trying too hard. No hashtags. No emojis unless they feel completely natural. Never use the word \"model\" or any fashion/influencer language.\n\nThe goal is to make someone curious enough to click the link. The best tweets hint at something without explaining it. They feel personal but not oversharing. Confident but not loud.\n\nGenerate 1 tweet. Keep it under 280 characters.\n\nDo not explain the tweet. Do not add options. Just output the tweet.",
|
||||
"link": "https://onlyfans.com/juniper_sky",
|
||||
"frequency": "2x_week",
|
||||
"startHour": 8,
|
||||
"endHour": 20
|
||||
},
|
||||
{
|
||||
"name": "personality",
|
||||
"prompt": "TODO: personality prompt goes here",
|
||||
"link": null,
|
||||
"frequency": "daily",
|
||||
"startHour": 8,
|
||||
"endHour": 20
|
||||
}
|
||||
]
|
||||
51
scheduler.js
Normal file
51
scheduler.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { execFileSync } from "child_process";
|
||||
import { readFileSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const promptName = process.argv[2];
|
||||
|
||||
if (!promptName) {
|
||||
console.error("Usage: node scheduler.js <prompt-name>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const prompts = JSON.parse(readFileSync(join(__dirname, "prompts.json"), "utf-8"));
|
||||
const config = prompts.find((p) => p.name === promptName);
|
||||
|
||||
if (!config) {
|
||||
console.error(`Prompt "${promptName}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Pick a random time in the window
|
||||
const windowMinutes = (config.endHour - config.startHour) * 60;
|
||||
const delayMinutes = Math.floor(Math.random() * windowMinutes);
|
||||
const hours = Math.floor(delayMinutes / 60) + config.startHour;
|
||||
const minutes = delayMinutes % 60;
|
||||
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
console.log(`[${promptName}] Scheduled for ${pad(hours)}:${pad(minutes)} PST`);
|
||||
|
||||
const now = new Date(
|
||||
new Date().toLocaleString("en-US", { timeZone: "America/Los_Angeles" })
|
||||
);
|
||||
const target = new Date(now);
|
||||
target.setHours(hours, minutes, 0, 0);
|
||||
|
||||
let delayMs = target.getTime() - now.getTime();
|
||||
if (delayMs <= 0) {
|
||||
console.log("Target time passed. Posting now.");
|
||||
delayMs = 0;
|
||||
}
|
||||
|
||||
console.log(`Waiting ${Math.round(delayMs / 60000)} minutes...`);
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
execFileSync("node", [join(__dirname, "bot.js"), promptName], { stdio: "inherit" });
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
}, delayMs);
|
||||
Reference in New Issue
Block a user