← back to Dear Bubbe Nextjs
scripts/twitter-smart-poster.js
416 lines
#!/usr/bin/env node
const fs = require('fs').promises;
const path = require('path');
const axios = require('axios');
const dotenv = require('dotenv');
// Load environment variables
dotenv.config({ path: path.join(__dirname, '..', '.env.local') });
const LOGS_DIR = path.join(__dirname, '..', 'logs');
const POSTED_FILE = path.join(LOGS_DIR, 'posted-tweets.json');
const STATS_FILE = path.join(LOGS_DIR, 'twitter-stats.json');
const LOG_FILE = path.join(LOGS_DIR, 'twitter-poster.log');
// Twitter API configuration
const TWITTER_API_URL = 'https://api.twitter.com/2/tweets';
const TWITTER_BEARER_TOKEN = process.env.TWITTER_BEARER_TOKEN;
// Posting configuration
const MAX_DAILY_POSTS = 100;
const MIN_POST_INTERVAL = 3 * 60 * 1000; // 3 minutes
const MAX_TWEET_LENGTH = 280;
// Viral hashtags for engagement
const VIRAL_HASHTAGS = [
'#JewishTwitter', '#JewishHumor', '#Bubbe', '#NoFilter',
'#SavageGrandma', '#TruthHurts', '#BubbeAI', '#AIGrandma',
'#JewishMom', '#GuiltTrip', '#Yiddish', '#Comedy',
'#Sarcasm', '#BrutalHonesty', '#Roasted', '#RealTalk'
];
// Celebrity/influencer handles to occasionally @ mention
const MENTION_TARGETS = [
'@jerryseinfeld', '@SethRogen', '@AmySchumer', '@BillyEichner',
'@SarahKSilverman', '@alydenisof', '@mindykaling', '@azizansari',
'@rickygervais', '@StephenCurry30', '@jimmyfallon', '@TheEllenShow',
'@Oprah', '@GordonRamsay', '@DrPhil', '@TheRock'
];
// Time slots for optimal engagement (EST)
const PEAK_HOURS = [
{ hour: 7, weight: 3 }, // Morning commute
{ hour: 8, weight: 3 },
{ hour: 12, weight: 4 }, // Lunch break
{ hour: 13, weight: 3 },
{ hour: 17, weight: 4 }, // Evening commute
{ hour: 18, weight: 4 },
{ hour: 19, weight: 5 }, // Prime time
{ hour: 20, weight: 5 },
{ hour: 21, weight: 4 },
{ hour: 22, weight: 3 }
];
// Bubbe's intro phrases
const INTRO_PHRASES = [
"Bubbe's hot take:",
"Bubbe says:",
"Listen up, bubbeleh:",
"Oy vey, let me tell you:",
"Bubbe's wisdom:",
"From Bubbe with tough love:",
"Bubbe's brutal honesty:",
"No filter alert:",
"Bubbe drops truth:",
"Real talk from Bubbe:"
];
async function loadStats() {
try {
const data = await fs.readFile(STATS_FILE, 'utf-8');
return JSON.parse(data);
} catch {
return {
startDate: new Date().toISOString(),
totalPosts: 0,
dailyPosts: {},
engagementTracking: [],
lastPostTime: null
};
}
}
async function saveStats(stats) {
await fs.writeFile(STATS_FILE, JSON.stringify(stats, null, 2));
}
async function loadPostedTweets() {
try {
const data = await fs.readFile(POSTED_FILE, 'utf-8');
return JSON.parse(data);
} catch {
return [];
}
}
async function savePostedTweets(tweets) {
await fs.writeFile(POSTED_FILE, JSON.stringify(tweets, null, 2));
}
async function log(message) {
const timestamp = new Date().toLocaleString();
const logMessage = `[${timestamp}] ${message}\n`;
await fs.appendFile(LOG_FILE, logMessage);
console.log(message);
}
function getCurrentHour() {
const now = new Date();
const estOffset = -5; // EST offset
const utc = now.getTime() + (now.getTimezoneOffset() * 60000);
const est = new Date(utc + (3600000 * estOffset));
return est.getHours();
}
function getPostWeight() {
const hour = getCurrentHour();
const peakHour = PEAK_HOURS.find(p => p.hour === hour);
return peakHour ? peakHour.weight : 1;
}
function calculatePostsForTimeSlot(remainingPosts, remainingHours) {
const weight = getPostWeight();
const baseRate = remainingPosts / remainingHours;
return Math.ceil(baseRate * weight);
}
async function getRecentConversation() {
try {
// Get a recent interesting conversation from the database
const conversationFiles = await fs.readdir(path.join(__dirname, '..', 'user-memories'));
if (conversationFiles.length === 0) {
await log('No conversation files found in user-memories');
return null;
}
// Filter for valid .md files
const mdFiles = conversationFiles.filter(f => f.endsWith('.md'));
if (mdFiles.length === 0) {
await log('No .md files found in user-memories');
return null;
}
const randomFile = mdFiles[Math.floor(Math.random() * mdFiles.length)];
const content = await fs.readFile(
path.join(__dirname, '..', 'user-memories', randomFile),
'utf-8'
);
// Extract a good conversation snippet - look for User: and Bubbe: patterns
const lines = content.split('\n');
// Find User: and Bubbe: lines
for (let i = 0; i < lines.length - 1; i++) {
if (lines[i].startsWith('User:') && lines[i + 1].startsWith('Bubbe:')) {
const userText = lines[i].replace('User:', '').trim();
const bubbeText = lines[i + 1].replace('Bubbe:', '').trim();
// Make sure we have actual content
if (userText.length > 5 && bubbeText.length > 10) {
await log(`Found conversation from ${randomFile}`);
return {
user: userText,
bubbe: bubbeText
};
}
}
}
await log(`No valid conversation found in ${randomFile}`);
return null;
} catch (error) {
await log(`Error reading conversations: ${error.message}`);
return null;
}
}
function formatTweet(conversation, includeHashtags = true, includeMention = false) {
const intro = INTRO_PHRASES[Math.floor(Math.random() * INTRO_PHRASES.length)];
let tweet = `${intro}\n\n`;
// Shorten if needed
let userText = conversation.user;
let bubbeText = conversation.bubbe;
// Calculate available space
let availableSpace = MAX_TWEET_LENGTH - tweet.length - 30; // Leave room for hashtags
if (includeMention && Math.random() < 0.1) { // 10% chance to @ someone
const mention = MENTION_TARGETS[Math.floor(Math.random() * MENTION_TARGETS.length)];
availableSpace -= mention.length + 1;
tweet = `${mention} ${tweet}`;
}
// Fit the conversation
if (userText.length + bubbeText.length > availableSpace) {
const halfSpace = Math.floor(availableSpace / 2);
if (userText.length > halfSpace) {
userText = userText.substring(0, halfSpace - 3) + '...';
}
if (bubbeText.length > halfSpace) {
bubbeText = bubbeText.substring(0, halfSpace - 3) + '...';
}
}
tweet += `User: "${userText}"\n\n`;
tweet += `Bubbe: "${bubbeText}"\n\n`;
if (includeHashtags) {
// Add 3-4 random hashtags
const numHashtags = 3 + Math.floor(Math.random() * 2);
const selectedHashtags = [];
for (let i = 0; i < numHashtags; i++) {
const hashtag = VIRAL_HASHTAGS[Math.floor(Math.random() * VIRAL_HASHTAGS.length)];
if (!selectedHashtags.includes(hashtag)) {
selectedHashtags.push(hashtag);
}
}
tweet += selectedHashtags.join(' ') + '\n\n';
}
tweet += '💬 Chat with Bubbe: Bubbe.AI';
// Ensure we don't exceed max length
if (tweet.length > MAX_TWEET_LENGTH) {
tweet = tweet.substring(0, MAX_TWEET_LENGTH - 3) + '...';
}
return tweet;
}
async function postToTwitter(tweet) {
try {
// For now, simulate posting since we need OAuth 2.0 setup
await log(`WOULD POST: ${tweet.substring(0, 100)}...`);
return { success: true, id: Date.now().toString() };
// Real posting code (needs OAuth 2.0 setup):
/*
const response = await axios.post(
TWITTER_API_URL,
{ text: tweet },
{
headers: {
'Authorization': `Bearer ${TWITTER_BEARER_TOKEN}`,
'Content-Type': 'application/json'
}
}
);
return { success: true, id: response.data.data.id };
*/
} catch (error) {
await log(`Error posting tweet: ${error.message}`);
return { success: false, error: error.message };
}
}
async function runSmartPoster() {
await log('Smart Twitter poster starting...');
const stats = await loadStats();
const postedTweets = await loadPostedTweets();
// Calculate today's posts
const today = new Date().toDateString();
const todaysPosts = stats.dailyPosts[today] || 0;
if (todaysPosts >= MAX_DAILY_POSTS) {
await log(`Already posted ${MAX_DAILY_POSTS} tweets today. Waiting for tomorrow.`);
return;
}
// Check if enough time has passed since last post
if (stats.lastPostTime) {
const timeSinceLastPost = Date.now() - new Date(stats.lastPostTime).getTime();
if (timeSinceLastPost < MIN_POST_INTERVAL) {
const waitTime = MIN_POST_INTERVAL - timeSinceLastPost;
await log(`Waiting ${Math.round(waitTime / 1000)} seconds before next post...`);
setTimeout(() => runSmartPoster(), waitTime);
return;
}
}
// Get conversation to post
const conversation = await getRecentConversation();
if (!conversation) {
await log('No conversation available to post. Trying again in 5 minutes...');
setTimeout(() => runSmartPoster(), 5 * 60 * 1000);
return;
}
// Format and post tweet
const includeMention = Math.random() < 0.15; // 15% chance to @ someone
const tweet = formatTweet(conversation, true, includeMention);
const result = await postToTwitter(tweet);
if (result.success) {
// Update stats
stats.totalPosts++;
stats.dailyPosts[today] = todaysPosts + 1;
stats.lastPostTime = new Date().toISOString();
stats.engagementTracking.push({
id: result.id,
time: new Date().toISOString(),
hasHashtags: true,
hasMention: includeMention
});
// Save posted tweet
postedTweets.push({
id: result.id,
text: tweet,
timestamp: new Date().toISOString()
});
await saveStats(stats);
await savePostedTweets(postedTweets);
await log(`Posted tweet ${stats.dailyPosts[today]}/${MAX_DAILY_POSTS} for today`);
// Calculate next post time based on remaining posts and time
const remainingPosts = MAX_DAILY_POSTS - stats.dailyPosts[today];
const hoursUntilMidnight = 24 - new Date().getHours();
const postsThisHour = calculatePostsForTimeSlot(remainingPosts, hoursUntilMidnight);
const intervalForHour = Math.max(
MIN_POST_INTERVAL,
Math.floor(60 * 60 * 1000 / postsThisHour)
);
await log(`Next post in ${Math.round(intervalForHour / 1000)} seconds`);
setTimeout(() => runSmartPoster(), intervalForHour);
} else {
// Retry in 5 minutes if failed
await log('Failed to post. Retrying in 5 minutes...');
setTimeout(() => runSmartPoster(), 5 * 60 * 1000);
}
}
async function generateWeeklyReport() {
const stats = await loadStats();
const report = {
reportDate: new Date().toISOString(),
weekStarted: stats.startDate,
totalPosts: stats.totalPosts,
dailyBreakdown: stats.dailyPosts,
averagePostsPerDay: stats.totalPosts / 7,
peakPostingTimes: analyzePeakTimes(stats.engagementTracking),
hashtagPerformance: 'Tracking engagement on viral hashtags',
mentionSuccess: `${stats.engagementTracking.filter(e => e.hasMention).length} celebrity mentions`,
recommendations: [
'Continue posting during peak hours (7-9pm EST)',
'Increase celebrity mentions for viral potential',
'Test new hashtag combinations'
]
};
const reportPath = path.join(LOGS_DIR, `weekly-report-${Date.now()}.json`);
await fs.writeFile(reportPath, JSON.stringify(report, null, 2));
await log(`Weekly report generated: ${reportPath}`);
console.log('📊 WEEKLY REPORT FOR STEVE:');
console.log(JSON.stringify(report, null, 2));
return report;
}
function analyzePeakTimes(tracking) {
const hourCounts = {};
tracking.forEach(post => {
const hour = new Date(post.time).getHours();
hourCounts[hour] = (hourCounts[hour] || 0) + 1;
});
const sorted = Object.entries(hourCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 3);
return sorted.map(([hour, count]) => ({
hour: parseInt(hour),
posts: count
}));
}
// Check if it's time for weekly report (every 7 days)
async function checkWeeklyReport() {
const stats = await loadStats();
const daysSinceStart = Math.floor(
(Date.now() - new Date(stats.startDate).getTime()) / (1000 * 60 * 60 * 24)
);
if (daysSinceStart > 0 && daysSinceStart % 7 === 0) {
await generateWeeklyReport();
}
}
// Main execution
async function main() {
// Ensure directories exist
await fs.mkdir(LOGS_DIR, { recursive: true });
await log('Twitter Smart Poster initialized with viral strategy');
await log(`Max ${MAX_DAILY_POSTS} posts/day, min ${MIN_POST_INTERVAL/1000}s between posts`);
// Start the smart posting loop
runSmartPoster();
// Check for weekly report every day at midnight
setInterval(checkWeeklyReport, 24 * 60 * 60 * 1000);
}
// Start the service
main().catch(console.error);