@ -163,34 +163,32 @@ def generate_article_tweet(author, post, persona):
def post_tweet ( author , tweet , reply_to_id = None ) :
"""
Post a tweet with real - time X API rate limit checking .
Updates rate_limit_info . json with tweet - specific limits .
Post a tweet after checking real - time X API rate limits .
Updates rate_limit_info . json with API - provided data .
"""
from foodie_config import X_API_CREDENTIALS
import logging
import tweepy
logger = logging . getLogger ( __name__ )
credentials = X_API_CREDENTIALS . get ( author [ " username " ] )
if not credentials :
logging . error ( f " No X credentials found for { author [ ' username ' ] } " )
logger . error ( f " No X credentials found for { author [ ' username ' ] } " )
return False
logging . debug ( f " Attempting to post tweet for { author [ ' username ' ] } (handle: { credentials [ ' x_username ' ] } ) " )
logging . debug ( f " Credentials: api_key= { credentials [ ' api_key ' ] [ : 4 ] } ..., access_token= { credentials [ ' access_token ' ] [ : 4 ] } ... " )
logging . debug ( f " Tweet content: { tweet } " )
# Check rate limit before posting
if check_author_rate_limit ( author ) :
logger . error ( f " Cannot post tweet for { author [ ' username ' ] } : Rate limit exceeded " )
return False
logger . debug ( f " Attempting to post tweet for { author [ ' username ' ] } (handle: { credentials [ ' x_username ' ] } ) " )
logger . debug ( f " Tweet content: { tweet } " )
if reply_to_id :
logging . debug ( f " Replying to tweet ID: { reply_to_id } " )
logger . debug ( f " Replying to tweet ID: { reply_to_id } " )
rate_limit_file = ' /home/shane/foodie_automator/rate_limit_info.json '
rate_limit_info = load_json_file ( rate_limit_file , default = { } )
username = author [ " username " ]
if username not in rate_limit_info :
rate_limit_info [ username ] = {
' tweet_remaining ' : 17 ,
' tweet_reset ' : time . time ( )
}
try :
client = tweepy . Client (
consumer_key = credentials [ " api_key " ] ,
@ -203,34 +201,38 @@ def post_tweet(author, tweet, reply_to_id=None):
in_reply_to_tweet_id = reply_to_id
)
tweet_id = response . data [ ' id ' ]
logging . info ( f " Successfully posted tweet { tweet_id } for { author [ ' username ' ] } (handle: { credentials [ ' x_username ' ] } ): { tweet } " )
logger . info ( f " Successfully posted tweet { tweet_id } for { author [ ' username ' ] } (handle: { credentials [ ' x_username ' ] } ): { tweet } " )
# Update rate limit info with fresh API data
remaining , reset = get_x_rate_limit_status ( author )
if remaining is not None and reset is not None :
rate_limit_info [ username ] = {
' tweet_remaining ' : max ( 0 , remaining - 1 ) , # Account for this tweet
' tweet_reset ' : reset
}
save_json_file ( rate_limit_file , rate_limit_info )
logger . info ( f " Updated rate limit for { username } : { rate_limit_info [ username ] [ ' tweet_remaining ' ] } remaining, reset at { datetime . fromtimestamp ( reset , tz = timezone . utc ) } " )
else :
logger . warning ( f " Failed to update rate limit info for { username } after posting " )
# Update tweet rate limits (local decrement, headers on 429)
rate_limit_info [ username ] [ ' tweet_remaining ' ] = max ( 0 , rate_limit_info [ username ] [ ' tweet_remaining ' ] - 1 )
save_json_file ( rate_limit_file , rate_limit_info )
logging . info ( f " Updated tweet rate limit for { username } : { rate_limit_info [ username ] [ ' tweet_remaining ' ] } remaining, reset at { datetime . fromtimestamp ( rate_limit_info [ username ] [ ' tweet_reset ' ] , tz = timezone . utc ) } " )
return { " id " : tweet_id }
except tweepy . TweepyException as e :
logging . error ( f " Failed to post tweet for { author [ ' username ' ] } (handle: { credentials [ ' x_username ' ] } ): { e } " )
logger . error ( f " Failed to post tweet for { author [ ' username ' ] } (handle: { credentials [ ' x_username ' ] } ): { e } " )
if hasattr ( e , ' response ' ) and e . response and e . response . status_code == 429 :
headers = e . response . headers
user_remaining = headers . get ( ' x-user-limit-24hour-remaining ' , 0 )
user_reset = headers . get ( ' x-user-limit-24hour-reset ' , time . time ( ) + 86400 )
try :
user_remaining = int ( user_remaining )
user_reset = int ( user_reset )
except ( ValueError , TypeError ) :
user_remaining = 0
user_reset = time . time ( ) + 86400
rate_limit_info [ username ] [ ' tweet_remaining ' ] = user_remaining
rate_limit_info [ username ] [ ' tweet_reset ' ] = user_reset
remaining , reset = get_x_rate_limit_status ( author )
if remaining is None :
remaining = 0
reset = time . time ( ) + 86400
rate_limit_info [ username ] = {
' tweet_remaining ' : remaining ,
' tweet_reset ' : reset
}
save_json_file ( rate_limit_file , rate_limit_info )
logging . info ( f " Rate limit exceeded for { username } : { user_ remaining} remaining, reset at { datetime . fromtimestamp ( user_ reset, tz = timezone . utc ) } " )
logger . info ( f " Rate limit exceeded for { username } : { remaining } remaining, reset at { datetime . fromtimestamp ( reset , tz = timezone . utc ) } " )
return False
except Exception as e :
logging . error ( f " Unexpected error posting tweet for { author [ ' username ' ] } (handle: { credentials [ ' x_username ' ] } ): { e } " , exc_info = True )
logger . error ( f " Unexpected error posting tweet for { author [ ' username ' ] } (handle: { credentials [ ' x_username ' ] } ): { e } " , exc_info = True )
return False
def select_best_persona ( interest_score , content = " " ) :
@ -712,13 +714,14 @@ def get_wp_tag_id(tag_name, wp_base_url, wp_username, wp_password):
logging . error ( f " Failed to get WP tag ID for ' { tag_name } ' : { e } " )
return None
def post_to_wp ( post_data , category , link , author , image_url , original_source , image_source , uploader , page_url , interest_score , post_id = None , should_post_tweet = True ) :
def post_to_wp ( post_data , category , link , author , image_url , original_source , image_source = " Pixabay " , uploader = None , page_url = None , interest_score = 4 , post_id = None , should_post_tweet = True ) :
"""
Post or update content to WordPress , optionally tweeting the post .
"""
import logging
import requests
from foodie_config import X_API_CREDENTIALS # Removed WP_CREDENTIALS
import base64
from foodie_config import X_API_CREDENTIALS
logger = logging . getLogger ( __name__ )
@ -728,7 +731,7 @@ def post_to_wp(post_data, category, link, author, image_url, original_source, im
wp_password = author . get ( " password " )
if not all ( [ wp_url , wp_username , wp_password ] ) :
logger . error ( f " Missing WordPress credentials for author: { author . get ( ' username ' , ' unknown ' ) } " )
logger . error ( f " Missing WordPress credentials for author: { wp_username or ' unknown ' } " )
return None , None
# Ensure wp_url ends with '/wp-json/wp/v2'
@ -737,61 +740,129 @@ def post_to_wp(post_data, category, link, author, image_url, original_source, im
else :
wp_base_url = wp_url
endpoint = f " { wp_base_url } /posts "
if post_id :
endpoint + = f " / { post_id } "
headers = {
" Authorization " : " Basic " + base64 . b64encode ( f " { wp_username } : { wp_password } " . encode ( ) ) . decode ( ) ,
" Content-Type " : " application/json "
# Hardcoded author ID map from old working version
author_id_map = {
" owenjohnson " : 10 ,
" javiermorales " : 2 ,
" aishapatel " : 3 ,
" trangnguyen " : 12 ,
" keishareid " : 13 ,
" lilamoreau " : 7
}
author_id = author_id_map . get ( wp_username , 5 ) # Default to ID 5 if username not found
# Get or create category ID
category_id = get_wp_category_id ( category , wp_base_url , wp_username , wp_password )
if not category_id :
category_id = create_wp_category ( category , wp_base_url , wp_username , wp_password )
try :
headers = {
" Authorization " : " Basic " + base64 . b64encode ( f " { wp_username } : { wp_password } " . encode ( ) ) . decode ( ) ,
" Content-Type " : " application/json "
}
# Test authentication
auth_test = requests . get ( f " { wp_base_url } /users/me " , headers = headers )
auth_test . raise_for_status ( )
logger . info ( f " Auth test passed for { wp_username } : { auth_test . json ( ) [ ' id ' ] } " )
# Get or create category ID
category_id = get_wp_category_id ( category , wp_base_url , wp_username , wp_password )
if not category_id :
logger . warning ( f " Failed to get or create category ' { category } ' , using default " )
category_id = 1 # Fallback to default category
payload = {
" title " : post_data [ " title " ] ,
" content " : post_data [ " content " ] ,
" status " : post_data [ " status " ] ,
" author " : wp_username , # Use username directly
" categories " : [ category_id ]
}
category_id = create_wp_category ( category , wp_base_url , wp_username , wp_password )
if not category_id :
logger . warning ( f " Failed to get or create category ' { category } ' , using default " )
category_id = 1 # Fallback to 'Uncategorized'
else :
logger . info ( f " Created new category ' { category } ' with ID { category_id } " )
else :
logger . info ( f " Found existing category ' { category } ' with ID { category_id } " )
# Handle tags
tags = [ 1 ] # Default tag ID (e.g., 'uncategorized')
if interest_score > = 9 :
picks_tag_id = get_wp_tag_id ( " Picks " , wp_base_url , wp_username , wp_password )
if picks_tag_id and picks_tag_id not in tags :
tags . append ( picks_tag_id )
logger . info ( f " Added ' Picks ' tag (ID: { picks_tag_id } ) due to high interest score: { interest_score } " )
# Format content with <p> tags
content = post_data [ " content " ]
if content is None :
logger . error ( f " Post content is None for title ' { post_data [ ' title ' ] } ' - using fallback " )
content = " Content unavailable. Check the original source for details. "
formatted_content = " \n " . join ( f " <p> { para } </p> " for para in content . split ( ' \n ' ) if para . strip ( ) )
# Upload image before posting
image_id = None
if image_url :
logger . info ( f " Attempting image upload for ' { post_data [ ' title ' ] } ' , URL: { image_url } , source: { image_source } " )
image_id = upload_image_to_wp ( image_url , post_data [ " title " ] , wp_base_url , wp_username , wp_password , image_source , uploader , page_url )
if not image_id :
logger . info ( f " Flickr upload failed for ' { post_data [ ' title ' ] } ' , falling back to Pixabay " )
pixabay_query = post_data [ " title " ] [ : 50 ]
image_url , image_source , uploader , page_url = get_image ( pixabay_query )
if image_url :
image_id = upload_image_to_wp ( image_url , post_data [ " title " ] , wp_base_url , wp_username , wp_password , image_source , uploader , page_url )
if not image_id :
logger . warning ( f " All image uploads failed for ' { post_data [ ' title ' ] } ' - posting without image " )
# Build payload
payload = {
" title " : post_data [ " title " ] ,
" content " : formatted_content ,
" status " : post_data [ " status " ] ,
" categories " : [ category_id ] ,
" tags " : tags ,
" author " : author_id ,
" meta " : {
" original_link " : link ,
" original_source " : original_source ,
" interest_score " : interest_score
}
}
if image_id :
payload [ " featured_media " ] = image_id
logger . info ( f " Set featured image for post ' { post_data [ ' title ' ] } ' : Media ID= { image_id } " )
try :
# Set endpoint for creating or updating post
endpoint = f " { wp_base_url } /posts/ { post_id } " if post_id else f " { wp_base_url } /posts "
logger . debug ( f " Sending POST to { endpoint } with payload: { json . dumps ( payload , indent = 2 ) } " )
response = requests . post ( endpoint , headers = headers , json = payload )
if response . status_code != 201 and response . status_code != 200 :
logger . error ( f " WordPress API error: { response . status_code } - { response . text } " )
response . raise_for_status ( )
post_id = response . json ( ) . get ( " id " )
post_url = response . json ( ) . get ( " link " )
post_info = response . json ( )
if not isinstance ( post_info , dict ) or " id " not in post_info :
raise ValueError ( f " Invalid WP response: { post_info } " )
post_id = post_info [ " id " ]
post_url = post_info [ " link " ]
logger . info ( f " { ' Updated ' if post_id else ' Posted ' } WordPress post: { post_data [ ' title ' ] } (ID: { post_id } ) " )
if image_url and not post_id : # Only upload image for new posts
media_id = upload_image_to_wp ( image_url , post_data [ " title " ] , wp_base_url , wp_username , wp_password , image_source , uploader , page_url )
if media_id :
requests . post (
f " { wp_base_url } /posts/ { post_id } " ,
headers = headers ,
json = { " featured_media " : media_id }
)
logger . info ( f " Set featured image (Media ID: { media_id } ) for post { post_id } " )
if should_post_tweet and post_url :
# Save to recent posts
timestamp = datetime . now ( timezone . utc ) . isoformat ( )
save_post_to_recent ( post_data [ " title " ] , post_url , wp_username , timestamp )
# Post tweet if enabled
if should_post_tweet :
credentials = X_API_CREDENTIALS . get ( post_data [ " author " ] )
if credentials :
tweet_text = f " { post_data [ ' title ' ] } \n { post_url } "
if post_tweet ( author , tweet_text ) : # Updated signature
if post_tweet ( author , tweet_text ) :
logger . info ( f " Successfully tweeted for post: { post_data [ ' title ' ] } " )
else :
logger . warning ( f " Failed to tweet for post: { post_data [ ' title ' ] } " )
return post_id , post_url
except requests . exceptions . HTTPError as e :
logger . error ( f " Failed to { ' update ' if post_id else ' post ' } WordPress post: { post_data [ ' title ' ] } : { e } - Response: { e . response . text } " , exc_info = True )
return None , None
except requests . exceptions . RequestException as e :
logger . error ( f " Failed to { ' update ' if post_id else ' post ' } WordPress post: { post_data [ ' title ' ] } : { e } " , exc_info = True )
return None , None
except Exception as e :
logger . error ( f " Failed to { ' update ' if post_id else ' post ' } WordPress post: { post_data [ ' title ' ] } : { e } " , exc_info = True )
return None , None
# Configure Flickr API with credentials
flickr_api . set_keys ( api_key = FLICKR_API_KEY , api_secret = FLICKR_API_SECRET )
@ -1102,41 +1173,52 @@ def check_rate_limit(response):
def check_author_rate_limit ( author , max_tweets = 17 , tweet_window_seconds = 86400 ) :
"""
Check if an author is rate - limited for tweets based on X API limits .
Check if an author is rate - limited for tweets using real - time X API data .
Returns ( can_post , remaining , reset_timestamp ) where can_post is False if rate - limited .
Caches API results in memory for the current script run .
"""
logger = logging . getLogger ( __name__ )
rate_limit_file = ' /home/shane/foodie_automator/rate_limit_info.json '
rate_limit_info = load_json_file ( rate_limit_file , default = { } )
current_time = time . time ( )
username = author [ ' username ' ]
if username not in rate_limit_info or not isinstance ( rate_limit_info [ username ] . get ( ' tweet_reset ' ) , ( int , float ) ) :
rate_limit_info [ username ] = {
' tweet_remaining ' : max_tweets ,
' tweet_reset ' : time . time ( )
}
logger . info ( f " Initialized tweet rate limit for { username } : { max_tweets } tweets available " )
# In-memory cache for rate limit status (reset per script run)
if not hasattr ( check_author_rate_limit , " cache " ) :
check_author_rate_limit . cache = { }
info = rate_limit_info [ username ]
current_time = time . time ( )
username = author [ ' username ' ]
cache_key = f " { username } _ { int ( current_time / / 60 ) } " # Cache for 1 minute
# Reset tweet limits if window expired or invalid
if current_time > = info . get ( ' tweet_reset ' , 0 ) or info . get ( ' tweet_reset ' , 0 ) < 1000000000 :
info [ ' tweet_remaining ' ] = max_tweets
info [ ' tweet_reset ' ] = current_time + tweet_window_seconds
logger . info ( f " Reset tweet rate limit for { username } : { max_tweets } tweets available " )
save_json_file ( rate_limit_file , rate_limit_info )
if cache_key in check_author_rate_limit . cache :
remaining , reset = check_author_rate_limit . cache [ cache_key ]
logger . debug ( f " Using cached rate limit for { username } : { remaining } remaining, reset at { datetime . fromtimestamp ( reset , tz = timezone . utc ) } " )
else :
remaining , reset = get_x_rate_limit_status ( author )
if remaining is None or reset is None :
# Fallback: Load from rate_limit_info.json or assume rate-limited
rate_limit_info = load_json_file ( rate_limit_file , default = { } )
if username not in rate_limit_info or current_time > = rate_limit_info . get ( username , { } ) . get ( ' tweet_reset ' , 0 ) :
rate_limit_info [ username ] = {
' tweet_remaining ' : 0 , # Conservative assumption
' tweet_reset ' : current_time + tweet_window_seconds
}
save_json_file ( rate_limit_file , rate_limit_info )
remaining = rate_limit_info [ username ] . get ( ' tweet_remaining ' , 0 )
reset = rate_limit_info [ username ] . get ( ' tweet_reset ' , current_time + tweet_window_seconds )
logger . warning ( f " X API rate limit check failed for { username } , using fallback: { remaining } remaining " )
check_author_rate_limit . cache [ cache_key ] = ( remaining , reset )
if info . get ( ' tweet_remaining ' , 0 ) < = 0 :
reset_time = datetime . fromtimestamp ( info [ ' tweet_reset ' ] , tz = timezone . utc ) . strftime ( ' % Y- % m- %d % H: % M: % S ' )
logger . info ( f " Author { username } is tweet rate-limited. Remaining: { info [ ' tweet_remaining ' ] } , Reset at: { reset_time } " )
return True
can_post = remaining > 0
if not can_post :
reset_time = datetime . fromtimestamp ( reset , tz = timezone . utc ) . strftime ( ' % Y- % m- %d % H: % M: % S ' )
logger . info ( f " Author { username } is rate-limited. Remaining: { remaining } , Reset at: { reset_time } " )
else :
logger . info ( f " Rate limit for { username } : { remaining } / { max_tweets } tweets remaining " )
logger . info ( f " Tweet rate limit for { username } : { info [ ' tweet_remaining ' ] } tweets remaining " )
return False
return can_post , remaining , reset
def get_next_author_round_robin ( ) :
"""
Select the next author using round - robin , respecting tweet rate limits .
Select the next author using round - robin , respecting real - time X API rate limits .
Returns None if no author is available .
"""
from foodie_config import AUTHORS
@ -1154,6 +1236,49 @@ def get_next_author_round_robin():
logger . warning ( " No authors available due to tweet rate limits. " )
return None
def get_x_rate_limit_status ( author ) :
"""
Query X API for the user ' s tweet rate limit status.
Returns ( remaining , reset_timestamp ) or ( None , None ) if the query fails .
"""
from foodie_config import X_API_CREDENTIALS
import tweepy
logger = logging . getLogger ( __name__ )
credentials = X_API_CREDENTIALS . get ( author [ " username " ] )
if not credentials :
logger . error ( f " No X credentials for { author [ ' username ' ] } " )
return None , None
try :
client = tweepy . Client (
consumer_key = credentials [ " api_key " ] ,
consumer_secret = credentials [ " api_secret " ] ,
access_token = credentials [ " access_token " ] ,
access_token_secret = credentials [ " access_token_secret " ]
)
# Tweepy v2 doesn't directly expose rate limit status, so use API v1.1 for rate limit check
api = tweepy . API (
tweepy . OAuth1UserHandler (
consumer_key = credentials [ " api_key " ] ,
consumer_secret = credentials [ " api_secret " ] ,
access_token = credentials [ " access_token " ] ,
access_token_secret = credentials [ " access_token_secret " ]
)
)
rate_limits = api . rate_limit_status ( )
tweet_limits = rate_limits [ " resources " ] [ " statuses " ] [ " /statuses/update " ]
remaining = tweet_limits [ " remaining " ]
reset = tweet_limits [ " reset " ]
logger . info ( f " X API rate limit for { author [ ' username ' ] } : { remaining } remaining, reset at { datetime . fromtimestamp ( reset , tz = timezone . utc ) } " )
return remaining , reset
except tweepy . TweepyException as e :
logger . error ( f " Failed to fetch X rate limit for { author [ ' username ' ] } : { e } " )
return None , None
except Exception as e :
logger . error ( f " Unexpected error fetching X rate limit for { author [ ' username ' ] } : { e } " , exc_info = True )
return None , None
def prepare_post_data ( summary , title , main_topic = None ) :
try :
logging . info ( f " Preparing post data for summary: { summary [ : 100 ] } ... " )