← back to Animate Museum Posts
src/postToX.js
222 lines
import { TwitterApi } from 'twitter-api-v2';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
import { sendPostNotification } from './sendEmail.js';
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Rotating intro phrases
const INTRO_PHRASES = [
'Public Domain Image of the Day',
'Art Spotlight',
'Masterpiece of the Day',
'From the Archives',
'Classical Art Feature',
'Timeless Beauty',
'Art History Moment',
'Museum Treasure'
];
// Art period hashtags
function getArtHashtags(metadata) {
const hashtags = ['#PublicDomain', '#Art'];
const title = (metadata.title || '').toLowerCase();
const artist = (metadata.artist || '').toLowerCase();
const year = parseInt(metadata.year) || 0;
// Period-based hashtags
if (year < 1500) hashtags.push('#Medieval', '#Renaissance');
else if (year < 1700) hashtags.push('#Baroque');
else if (year < 1800) hashtags.push('#Neoclassical');
else if (year < 1870) hashtags.push('#Romanticism');
else if (year < 1910) hashtags.push('#Impressionism');
else if (year < 1950) hashtags.push('#ModernArt');
// Artist-based hashtags
if (artist.includes('van gogh')) hashtags.push('#VanGogh');
if (artist.includes('rembrandt')) hashtags.push('#Rembrandt');
if (artist.includes('vermeer')) hashtags.push('#Vermeer');
if (artist.includes('monet')) hashtags.push('#Monet');
// Subject-based hashtags
if (title.includes('portrait')) hashtags.push('#Portrait');
if (title.includes('landscape')) hashtags.push('#Landscape');
if (title.includes('still life') || title.includes('flower') || title.includes('sunflower')) hashtags.push('#StillLife');
// Museum hashtag
if (metadata.museum?.includes('Met')) hashtags.push('#MetMuseum');
if (metadata.museum?.includes('Rijks')) hashtags.push('#Rijksmuseum');
if (metadata.museum?.includes('Chicago')) hashtags.push('#ArtInstituteChicago');
return hashtags.slice(0, 5).join(' '); // Max 5 hashtags
}
function generateTweetText(metadata) {
const intro = INTRO_PHRASES[Math.floor(Math.random() * INTRO_PHRASES.length)];
const hashtags = getArtHashtags(metadata);
const museum = metadata.museum || metadata.sourceMuseum || 'Public Domain';
const tweetText = `${intro}
"${metadata.title}"
${metadata.artist} (${metadata.year})
${museum}
${hashtags}`;
// Ensure under 280 chars
if (tweetText.length > 280) {
// Truncate title if needed
const shortTitle = metadata.title.length > 30
? metadata.title.substring(0, 27) + '...'
: metadata.title;
return `${intro}\n\n"${shortTitle}"\n${metadata.artist}\n\n${hashtags}`;
}
return tweetText;
}
function generateAltText(metadata) {
const description = metadata.description || '';
const period = metadata.period || metadata.style || '';
let altText = `${metadata.title} by ${metadata.artist}, ${metadata.year}.`;
if (period) altText += ` ${period}.`;
if (description && description.length < 500) altText += ` ${description}`;
// Twitter alt text max is 1000 chars
return altText.substring(0, 1000);
}
export async function postToX(animationResult) {
try {
if (!process.env.TWITTER_API_KEY || !process.env.TWITTER_ACCESS_TOKEN) {
throw new Error('Twitter API credentials not configured');
}
const client = new TwitterApi({
appKey: process.env.TWITTER_API_KEY,
appSecret: process.env.TWITTER_API_SECRET,
accessToken: process.env.TWITTER_ACCESS_TOKEN,
accessSecret: process.env.TWITTER_ACCESS_SECRET,
});
const metadata = animationResult.artworkMetadata;
// Use animated video/image if available, otherwise use original
const mediaPath = animationResult.animatedVideo || animationResult.animatedImage || animationResult.originalImage;
if (!fs.existsSync(mediaPath)) {
throw new Error(`Media file not found: ${mediaPath}`);
}
const tweetText = generateTweetText(metadata);
try {
console.log(`Uploading media: ${path.basename(mediaPath)}`);
const mediaId = await client.v1.uploadMedia(mediaPath);
// Add alt text for accessibility
const altText = generateAltText(metadata);
try {
await client.v1.createMediaMetadata(mediaId, { alt_text: { text: altText } });
console.log('Alt text added for accessibility');
} catch (altError) {
console.log('Could not add alt text:', altError.message);
}
const tweet = await client.v2.tweet({
text: tweetText,
media: {
media_ids: [mediaId]
}
});
console.log('Posted to X.com successfully!');
console.log(`Tweet ID: ${tweet.data.id}`);
console.log(`Tweet text: ${tweetText}`);
await sendPostNotification(tweetText, metadata, mediaPath);
const logEntry = {
timestamp: new Date().toISOString(),
tweetId: tweet.data.id,
tweetText: tweetText,
artwork: metadata,
mediaPath: mediaPath
};
const logsDir = path.join(__dirname, '..', 'logs');
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
const logFile = path.join(logsDir, 'posts.log');
fs.appendFileSync(logFile, JSON.stringify(logEntry) + '\n');
return tweet;
} catch (uploadError) {
console.error('Media upload failed:', uploadError.message);
if (uploadError.data) {
console.error('Error details:', uploadError.data);
}
console.log('Falling back to text-only tweet...');
const textOnlyTweet = await client.v2.tweet({
text: tweetText
});
console.log('Posted text-only tweet to X.com');
console.log(`Tweet ID: ${textOnlyTweet.data.id}`);
await sendPostNotification(tweetText, metadata, null);
return textOnlyTweet;
}
} catch (error) {
console.error('Error posting to X.com:', error.message);
if (error.code === 401) {
console.error('Authentication failed. Please check your Twitter API credentials.');
} else if (error.code === 403) {
console.error('Permission denied. Your app may need Elevated access for media upload.');
console.error('Apply for Elevated access at: https://developer.twitter.com/en/portal/products/elevated');
}
throw error;
}
}
export async function testCredentials() {
try {
const client = new TwitterApi({
appKey: process.env.TWITTER_API_KEY,
appSecret: process.env.TWITTER_API_SECRET,
accessToken: process.env.TWITTER_ACCESS_TOKEN,
accessSecret: process.env.TWITTER_ACCESS_SECRET,
});
const user = await client.v2.me();
console.log('Connected to X.com as:', user.data.username);
try {
const limits = await client.v2.rateLimitStatus();
console.log('API access level verified');
} catch (e) {
console.log('Note: App may need Elevated access for media uploads');
}
return true;
} catch (error) {
console.error('Failed to connect to X.com:', error.message);
return false;
}
}