Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- const fs = require('fs').promises;
- const schedule = require('node-schedule');
- const { exec } = require('child_process');
- const util = require('util');
- const execPromise = util.promisify(exec);
- class TweetReminder {
- constructor(archivePath, options = {}) {
- this.archivePath = archivePath;
- this.debugLog = options.debugLog || (() => {});
- this.options = {
- useMacNotifications: options.useMacNotifications || false
- };
- this.tweets = [];
- }
- async initialize() {
- try {
- this.tweets = await this.loadTweets();
- this.debugLog(`Loaded ${this.tweets.length} tweets`);
- return true;
- } catch (error) {
- console.error('Failed to initialize:', error);
- return false;
- }
- }
- async loadTweets() {
- try {
- let data = await fs.readFile(this.archivePath, 'utf8');
- if (data.startsWith('window.YTD.tweets.part0 = ')) {
- data = data.replace('window.YTD.tweets.part0 = ', '');
- }
- const parsedData = JSON.parse(data);
- const tweets = parsedData.map(item => item.tweet);
- const originalTweets = tweets.filter(tweet => {
- return !tweet.retweeted &&
- !tweet.full_text.startsWith('RT ') &&
- !(tweet.in_reply_to_user_id || tweet.in_reply_to_status_id);
- });
- this.debugLog(`Found ${originalTweets.length} original tweets`);
- return originalTweets;
- } catch (error) {
- console.error('Error loading tweets:', error);
- return [];
- }
- }
- selectInterestingTweet(seenIds = new Set()) {
- if (!this.tweets.length) {
- return null;
- }
- // Define what we consider "interesting" engagement
- const MIN_ENGAGEMENT = 5; // Total of likes + retweets
- const VIRAL_THRESHOLD = 1000; // What we consider "viral"
- const availableTweets = this.tweets.filter(t => !seenIds.has(t.id_str));
- const weightedTweets = availableTweets.map(tweet => {
- const likes = parseInt(tweet.favorite_count || 0);
- const retweets = parseInt(tweet.retweet_count || 0);
- const totalEngagement = likes + retweets;
- // Start with a base random component
- let weight = Math.random(); // Full random base weight
- // Only add engagement weight if it meets minimum threshold
- if (totalEngagement >= MIN_ENGAGEMENT) {
- // Cap engagement at viral threshold
- const cappedEngagement = Math.min(totalEngagement, VIRAL_THRESHOLD);
- // For tweets below viral threshold, use logarithmic scaling
- // For viral tweets, they all get the same bonus
- const engagementWeight = Math.log10(cappedEngagement) / Math.log10(VIRAL_THRESHOLD);
- weight += engagementWeight * 0.5; // Engagement is worth up to 50% of weight
- // Small boost for having both likes and retweets
- if (likes > 0 && retweets > 0) {
- weight *= 1.1;
- }
- }
- // Tiny boost for longer, non-link tweets
- const text = tweet.full_text;
- if (text.length > 60 && !text.includes('http')) {
- weight *= 1.05;
- }
- // Tiny boost for tweets that are part of a thread
- if (tweet.in_reply_to_status_id_str) {
- weight *= 1.05;
- }
- this.debugLog(`Tweet weight calculation:
- Text: ${text.substring(0, 50)}...
- Engagement: ${totalEngagement}
- Final weight: ${weight.toFixed(3)}
- `);
- return { tweet, weight };
- });
- // Use weighted random selection
- const totalWeight = weightedTweets.reduce((sum, { weight }) => sum + weight, 0);
- let randomValue = Math.random() * totalWeight;
- for (const { tweet, weight } of weightedTweets) {
- randomValue -= weight;
- if (randomValue <= 0) {
- return tweet;
- }
- }
- return weightedTweets[0]?.tweet || null;
- }
- formatTweetReminder(tweet) {
- const text = tweet.full_text;
- const tweetDate = new Date(tweet.created_at).toLocaleDateString('en-US', {
- year: 'numeric',
- month: 'long',
- day: 'numeric'
- });
- // Create the tweet URL using the tweet ID
- const tweetUrl = `https://twitter.com/i/status/${tweet.id_str}`;
- // Create the engagement string
- const likes = parseInt(tweet.favorite_count || 0);
- const retweets = parseInt(tweet.retweet_count || 0);
- const engagement = `💖 ${likes} like${likes !== 1 ? 's' : ''}, 🔄 ${retweets} retweet${retweets !== 1 ? 's' : ''}`;
- return `
- 📅 On ${tweetDate}, you tweeted:
- ===========================================
- ${text}
- 📊 Engagement: ${engagement}
- ===========================================
- 🔗 ${tweetUrl}`.trim();
- }
- async sendMacNotification(tweets) {
- try {
- // Show notification for first tweet
- const firstTweet = tweets[0];
- const shortText = firstTweet.full_text.substring(0, 100) +
- (firstTweet.full_text.length > 100 ? '...' : '');
- const cleanShortText = shortText.replace(/[\\"`]/g, "'");
- // First, show the notification
- await execPromise(`osascript -e 'display notification "${cleanShortText}" with title "Tweet Memory" subtitle "Click Show for digest" sound name "Glass"'`);
- // Create a temporary HTML file
- const { writeFile, unlink } = require('fs').promises;
- const os = require('os');
- const path = require('path');
- const tempPath = path.join(os.tmpdir(), 'tweet-digest.html');
- // Format tweets as HTML
- const tweetsHtml = tweets.map((tweet, i) => {
- const tweetDate = new Date(tweet.created_at).toLocaleDateString('en-US', {
- year: 'numeric',
- month: 'long',
- day: 'numeric'
- });
- const likes = parseInt(tweet.favorite_count || 0);
- const retweets = parseInt(tweet.retweet_count || 0);
- const stats = `${likes} ${likes === 1 ? 'like' : 'likes'}, ${retweets} ${retweets === 1 ? 'retweet' : 'retweets'}`;
- return `
- <div class="tweet">
- <div class="header">Tweet ${i + 1}</div>
- <div class="date">${tweetDate}</div>
- <div class="text">${tweet.full_text}</div>
- <div class="stats">${stats}</div>
- <div class="url">
- <a href="https://twitter.com/i/status/${tweet.id_str}" target="_blank">
- twitter.com/i/status/${tweet.id_str}
- </a>
- </div>
- </div>
- ${i < tweets.length - 1 ? '<hr/>' : ''}
- `.trim();
- }).join('');
- const html = `
- <!DOCTYPE html>
- <html>
- <head>
- <meta charset="UTF-8">
- <style>
- body {
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
- padding: 20px;
- max-width: 800px;
- margin: 0 auto;
- line-height: 1.4;
- }
- .tweet { margin-bottom: 20px; }
- .header { font-weight: bold; margin-bottom: 5px; }
- .date { color: #666; margin-bottom: 10px; }
- .text { margin: 15px 0; white-space: pre-wrap; }
- .stats { color: #666; margin-bottom: 5px; }
- .url a { color: #1da1f2; text-decoration: none; }
- .url a:hover { text-decoration: underline; }
- hr { border: none; border-top: 1px solid #eee; margin: 20px 0; }
- </style>
- </head>
- <body>${tweetsHtml}</body>
- </html>
- `;
- await writeFile(tempPath, html);
- await execPromise(`open "${tempPath}"`);
- // Clean up after a delay
- setTimeout(() => {
- unlink(tempPath).catch(() => {});
- }, 1000);
- return true;
- } catch (error) {
- console.error('Error showing tweets:', error);
- return false;
- }
- }
- async sendDailyReminder() {
- const digest = [];
- const seenIds = new Set();
- // First collect all 5 tweets
- for (let i = 0; i < 5; i++) {
- const tweet = this.selectInterestingTweet(seenIds);
- if (!tweet) break;
- seenIds.add(tweet.id_str);
- digest.push(tweet);
- }
- // Print to console immediately
- console.log('\n=== Tweet Memory Digest ===\n');
- for (let i = 0; i < digest.length; i++) {
- console.log(`[${i + 1} of 5]\n`);
- console.log(this.formatTweetReminder(digest[i]));
- if (i < digest.length - 1) {
- console.log('\n---\n');
- }
- }
- console.log(); // Final newline
- // Then show notification if enabled
- if (process.platform === 'darwin' && this.options?.useMacNotifications) {
- await this.sendMacNotification(digest);
- }
- }
- }
- async function main() {
- const args = process.argv.slice(2);
- if (args.includes('--help') || args.includes('-h') || args.length === 0) {
- console.log(`
- Usage: node tweet-reminder <archive-path> [options]
- Description:
- Surfaces interesting tweets from your Twitter archive as a daily digest of 5 tweets,
- with an intelligent selection algorithm that balances engagement and randomness.
- Shows tweets in terminal and optionally as system notifications.
- Arguments:
- archive-path Path to your Twitter archive JSON file
- (usually named "tweets.js" in your archive)
- Options:
- --help, -h Show this help message
- --test Run once and exit (don't schedule daily reminders)
- --debug Show debug logging information
- --notify Show macOS notification for the first tweet (includes clickable dialog)
- Examples:
- node tweet-reminder ./tweets.js
- node tweet-reminder ./tweets.js --test
- node tweet-reminder ./tweets.js --notify
- node tweet-reminder ./tweets.js --test --notify
- node tweet-reminder --help
- Details:
- - Runs daily at noon by default
- - Shows 5 different tweets each time
- - Prefers tweets with meaningful engagement
- - Includes date, engagement stats, and original URL for each tweet
- - With --notify, shows system notification for the first tweet
- `);
- process.exit(0);
- }
- const archivePath = args.find(arg => !arg.startsWith('-'));
- if (!archivePath) {
- console.error('Error: Archive path is required');
- process.exit(1);
- }
- const isDebug = args.includes('--debug');
- const debugLog = (...args) => {
- if (isDebug) {
- console.log(...args);
- }
- };
- try {
- await fs.access(archivePath);
- } catch (error) {
- console.error(`Error: Cannot access ${archivePath}`);
- console.error('Please check if the file exists and you have permission to read it.');
- process.exit(1);
- }
- const reminder = new TweetReminder(archivePath, {
- debugLog,
- useMacNotifications: args.includes('--notify')
- });
- const initialized = await reminder.initialize();
- if (!initialized) {
- console.error('Failed to initialize tweet reminder');
- process.exit(1);
- }
- if (args.includes('--test')) {
- await reminder.sendDailyReminder();
- process.exit(0);
- }
- // Run immediately and schedule future runs
- await reminder.sendDailyReminder();
- schedule.scheduleJob('0 12 * * *', async () => {
- debugLog('Running scheduled digest...');
- await reminder.sendDailyReminder();
- });
- console.log('\nTweet digest service started! Checking for tweets daily at 12:00 PM.');
- console.log('Press Ctrl+C to exit');
- }
- main().catch(err => {
- console.error('Error in main:', err);
- process.exit(1);
- });
Advertisement
Add Comment
Please, Sign In to add comment