add check once for rate limiting X
This commit is contained in:
+12
-3
@@ -109,6 +109,9 @@ def setup_logging():
|
|||||||
# Call setup_logging immediately
|
# Call setup_logging immediately
|
||||||
setup_logging()
|
setup_logging()
|
||||||
|
|
||||||
|
check_author_rate_limit.script_run_id = int(time.time())
|
||||||
|
logging.info(f"Set script_run_id to {check_author_rate_limit.script_run_id}")
|
||||||
|
|
||||||
posted_titles_data = load_json_file(POSTED_TITLES_FILE, EXPIRATION_HOURS)
|
posted_titles_data = load_json_file(POSTED_TITLES_FILE, EXPIRATION_HOURS)
|
||||||
posted_titles = set(entry["title"] for entry in posted_titles_data)
|
posted_titles = set(entry["title"] for entry in posted_titles_data)
|
||||||
used_images = set(entry["title"] for entry in load_json_file(USED_IMAGES_FILE, IMAGE_EXPIRATION_DAYS) if "title" in entry)
|
used_images = set(entry["title"] for entry in load_json_file(USED_IMAGES_FILE, IMAGE_EXPIRATION_DAYS) if "title" in entry)
|
||||||
@@ -417,7 +420,9 @@ def curate_from_rss(posted_titles_data, posted_titles, used_images_data, used_im
|
|||||||
logging.info(f"Saved image '{image_url}' to {USED_IMAGES_FILE}")
|
logging.info(f"Saved image '{image_url}' to {USED_IMAGES_FILE}")
|
||||||
|
|
||||||
logging.info(f"***** SUCCESS: Posted '{post_data['title']}' (ID: {post_id or 'N/A'}) from RSS *****")
|
logging.info(f"***** SUCCESS: Posted '{post_data['title']}' (ID: {post_id or 'N/A'}) from RSS *****")
|
||||||
return post_data, category, random.randint(0, 1800)
|
# Sleep for 20 to 30 minutes (1200 to 1800 seconds)
|
||||||
|
sleep_time = random.randint(1200, 1800)
|
||||||
|
return post_data, category, sleep_time
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to post to WordPress for '{title}': {e}", exc_info=True)
|
logging.error(f"Failed to post to WordPress for '{title}': {e}", exc_info=True)
|
||||||
@@ -435,10 +440,14 @@ def curate_from_rss(posted_titles_data, posted_titles, used_images_data, used_im
|
|||||||
is_posting = False
|
is_posting = False
|
||||||
|
|
||||||
logging.info("No interesting RSS article found after attempts")
|
logging.info("No interesting RSS article found after attempts")
|
||||||
return None, None, random.randint(600, 1800)
|
# Sleep for 20 to 30 minutes (1200 to 1800 seconds)
|
||||||
|
sleep_time = random.randint(1200, 1800)
|
||||||
|
return None, None, sleep_time
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Unexpected error in curate_from_rss: {e}", exc_info=True)
|
logging.error(f"Unexpected error in curate_from_rss: {e}", exc_info=True)
|
||||||
return None, None, random.randint(600, 1800)
|
# Sleep for 20 to 30 minutes (1200 to 1800 seconds)
|
||||||
|
sleep_time = random.randint(1200, 1800)
|
||||||
|
return None, None, sleep_time
|
||||||
|
|
||||||
def run_rss_automator():
|
def run_rss_automator():
|
||||||
lock_fd = None
|
lock_fd = None
|
||||||
|
|||||||
+93
-60
@@ -162,23 +162,23 @@ def generate_article_tweet(author, post, persona):
|
|||||||
logging.info(f"Generated tweet: {tweet}")
|
logging.info(f"Generated tweet: {tweet}")
|
||||||
return tweet
|
return tweet
|
||||||
|
|
||||||
def post_tweet(author, content, media_ids=None, reply_to_id=None):
|
def post_tweet(author, content, media_ids=None, reply_to_id=None, tweet_type="rss"):
|
||||||
"""
|
"""
|
||||||
Post a tweet for an author using X API v2.
|
Post a tweet for the given author using X API v2.
|
||||||
Returns (tweet_id, tweet_data) if successful, (None, None) if rate-limited or failed.
|
Returns (tweet_id, tweet_data) on success, (None, None) on failure.
|
||||||
"""
|
"""
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
username = author['username']
|
username = author['username']
|
||||||
credentials = X_API_CREDENTIALS.get(username)
|
credentials = X_API_CREDENTIALS.get(username)
|
||||||
if not credentials:
|
if not credentials:
|
||||||
logger.error(f"No X API credentials for {username}")
|
logger.error(f"No X API credentials found for {username}")
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
# Check rate limit
|
# Check rate limit
|
||||||
can_post, remaining, reset = check_author_rate_limit(author)
|
can_post, remaining, reset = check_author_rate_limit(author)
|
||||||
if not can_post:
|
if not can_post:
|
||||||
reset_time = datetime.fromtimestamp(reset, tz=timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
|
reset_time = datetime.fromtimestamp(reset, tz=timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
logger.info(f"Cannot post tweet for {username}: rate-limited. Remaining: {remaining}, Reset at: {reset_time}")
|
logger.info(f"Cannot post {tweet_type} tweet for {username}: rate-limited. Remaining: {remaining}, Reset at: {reset_time}")
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
oauth = OAuth1(
|
oauth = OAuth1(
|
||||||
@@ -198,31 +198,37 @@ def post_tweet(author, content, media_ids=None, reply_to_id=None):
|
|||||||
response = requests.post(url, json=payload, auth=oauth)
|
response = requests.post(url, json=payload, auth=oauth)
|
||||||
headers = response.headers
|
headers = response.headers
|
||||||
|
|
||||||
# Update rate limit info
|
# Update in-run tweet counter
|
||||||
rate_limit_file = '/home/shane/foodie_automator/rate_limit_info.json'
|
rate_limit_file = '/home/shane/foodie_automator/rate_limit_info.json'
|
||||||
rate_limit_info = load_json_file(rate_limit_file, default={})
|
rate_limit_info = load_json_file(rate_limit_file, default={})
|
||||||
remaining = int(headers.get('x-user-limit-24hour-remaining', remaining))
|
if username in rate_limit_info:
|
||||||
reset = int(headers.get('x-user-limit-24hour-reset', reset))
|
author_info = rate_limit_info[username]
|
||||||
rate_limit_info[username] = {'tweet_remaining': remaining, 'tweet_reset': reset}
|
author_info['tweets_posted_in_run'] = author_info.get('tweets_posted_in_run', 0) + 1
|
||||||
save_json_file(rate_limit_file, rate_limit_info)
|
remaining = author_info['tweet_remaining'] - author_info['tweets_posted_in_run']
|
||||||
|
rate_limit_info[username] = author_info
|
||||||
|
save_json_file(rate_limit_file, rate_limit_info)
|
||||||
|
logger.info(f"Updated in-run tweet counter for {username} ({tweet_type}): {remaining}/17 tweets remaining")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Rate limit info not found for {username}, assuming quota exhausted")
|
||||||
|
remaining = 0
|
||||||
|
|
||||||
if response.status_code == 201:
|
if response.status_code == 201:
|
||||||
tweet_data = response.json()
|
tweet_data = response.json()
|
||||||
tweet_id = tweet_data.get('data', {}).get('id')
|
tweet_id = tweet_data.get('data', {}).get('id')
|
||||||
logger.info(f"Successfully tweeted for {username}: {content[:50]}... (ID: {tweet_id})")
|
logger.info(f"Successfully tweeted {tweet_type} for {username}: {content[:50]}... (ID: {tweet_id})")
|
||||||
return tweet_id, tweet_data
|
return tweet_id, tweet_data
|
||||||
elif response.status_code == 429:
|
elif response.status_code == 429:
|
||||||
logger.info(f"Rate limit exceeded for {username}: {remaining} remaining, reset at {datetime.fromtimestamp(reset, tz=timezone.utc)}")
|
logger.info(f"Rate limit exceeded for {username} ({tweet_type}): {remaining} remaining, reset at {datetime.fromtimestamp(reset, tz=timezone.utc)}")
|
||||||
return None, None
|
return None, None
|
||||||
elif response.status_code == 403:
|
elif response.status_code == 403:
|
||||||
logger.error(f"403 Forbidden for {username}: {response.text}")
|
logger.error(f"403 Forbidden for {username} ({tweet_type}): {response.text}")
|
||||||
return None, None
|
return None, None
|
||||||
else:
|
else:
|
||||||
logger.error(f"Failed to tweet for {username}: {response.status_code} - {response.text}")
|
logger.error(f"Failed to post {tweet_type} tweet for {username}: {response.status_code} - {response.text}")
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error posting tweet for {username}: {e}", exc_info=True)
|
logger.error(f"Unexpected error posting {tweet_type} tweet for {username}: {e}", exc_info=True)
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
def select_best_persona(interest_score, content=""):
|
def select_best_persona(interest_score, content=""):
|
||||||
@@ -832,7 +838,7 @@ def post_to_wp(post_data, category, link, author, image_url, original_source, im
|
|||||||
timestamp = datetime.now(timezone.utc).isoformat()
|
timestamp = datetime.now(timezone.utc).isoformat()
|
||||||
save_post_to_recent(post_data["title"], post_url, wp_username, timestamp)
|
save_post_to_recent(post_data["title"], post_url, wp_username, timestamp)
|
||||||
|
|
||||||
# Post tweet if enabled
|
# Post tweet if enabled
|
||||||
if should_post_tweet:
|
if should_post_tweet:
|
||||||
credentials = X_API_CREDENTIALS.get(post_data["author"])
|
credentials = X_API_CREDENTIALS.get(post_data["author"])
|
||||||
if credentials:
|
if credentials:
|
||||||
@@ -845,7 +851,7 @@ def post_to_wp(post_data, category, link, author, image_url, original_source, im
|
|||||||
"url": post_url
|
"url": post_url
|
||||||
}
|
}
|
||||||
tweet_text = generate_article_tweet(author, tweet_post, persona)
|
tweet_text = generate_article_tweet(author, tweet_post, persona)
|
||||||
tweet_id, tweet_data = post_tweet(author, tweet_text)
|
tweet_id, tweet_data = post_tweet(author, tweet_text, tweet_type="rss")
|
||||||
if tweet_id:
|
if tweet_id:
|
||||||
logger.info(f"Successfully tweeted for post: {post_data['title']} (Tweet ID: {tweet_id})")
|
logger.info(f"Successfully tweeted for post: {post_data['title']} (Tweet ID: {tweet_id})")
|
||||||
else:
|
else:
|
||||||
@@ -1162,52 +1168,81 @@ def select_best_author(content, interest_score):
|
|||||||
|
|
||||||
def check_author_rate_limit(author, max_tweets=17, tweet_window_seconds=86400):
|
def check_author_rate_limit(author, max_tweets=17, tweet_window_seconds=86400):
|
||||||
"""
|
"""
|
||||||
Check if an author is rate-limited for tweets using real-time X API v2 data.
|
Check if an author can post based on their X API Free tier quota (17 tweets per 24 hours per app).
|
||||||
|
Posts a test tweet only on script restart or for new authors, then tracks tweets in rate_limit_info.json.
|
||||||
Returns (can_post, remaining, reset_timestamp) where can_post is True if tweets are available.
|
Returns (can_post, remaining, reset_timestamp) where can_post is True if tweets are available.
|
||||||
Caches API results in memory for 5 minutes.
|
|
||||||
Falls back to rate_limit_info.json or assumes 1 tweet remaining if API fails.
|
|
||||||
"""
|
"""
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
rate_limit_file = '/home/shane/foodie_automator/rate_limit_info.json'
|
rate_limit_file = '/home/shane/foodie_automator/rate_limit_info.json'
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
|
||||||
# In-memory cache
|
# Load rate limit info
|
||||||
if not hasattr(check_author_rate_limit, "cache"):
|
rate_limit_info = load_json_file(rate_limit_file, default={})
|
||||||
check_author_rate_limit.cache = {}
|
|
||||||
|
# Get script run ID (set at startup in foodie_automator_rss.py)
|
||||||
|
if not hasattr(check_author_rate_limit, "script_run_id"):
|
||||||
|
check_author_rate_limit.script_run_id = int(current_time)
|
||||||
|
logger.info(f"Set script_run_id to {check_author_rate_limit.script_run_id}")
|
||||||
|
|
||||||
username = author['username']
|
username = author['username']
|
||||||
cache_key = f"{username}_{int(current_time // 300)}" # Cache for 5 minutes
|
|
||||||
|
|
||||||
if cache_key in check_author_rate_limit.cache:
|
# Initialize or update author entry
|
||||||
remaining, reset = check_author_rate_limit.cache[cache_key]
|
if username not in rate_limit_info:
|
||||||
logger.debug(f"Using cached rate limit for {username}: {remaining} remaining, reset at {datetime.fromtimestamp(reset, tz=timezone.utc)}")
|
rate_limit_info[username] = {
|
||||||
else:
|
'tweet_remaining': max_tweets,
|
||||||
|
'tweet_reset': current_time + tweet_window_seconds,
|
||||||
|
'tweets_posted_in_run': 0,
|
||||||
|
'script_run_id': 0 # Force test tweet for new authors
|
||||||
|
}
|
||||||
|
|
||||||
|
author_info = rate_limit_info[username]
|
||||||
|
script_run_id = author_info.get('script_run_id', 0)
|
||||||
|
|
||||||
|
# If script restarted or new author, post a test tweet to sync quota
|
||||||
|
if script_run_id != check_author_rate_limit.script_run_id:
|
||||||
|
logger.info(f"Script restart detected for {username}, posting test tweet to sync quota")
|
||||||
remaining, reset = get_x_rate_limit_status(author)
|
remaining, reset = get_x_rate_limit_status(author)
|
||||||
if remaining is None or reset is None:
|
if remaining is None or reset is None:
|
||||||
# Fallback: Load from rate_limit_info.json or assume 1 tweet remaining
|
# Fallback: Use last known quota or assume 0 remaining
|
||||||
rate_limit_info = load_json_file(rate_limit_file, default={})
|
if current_time < author_info.get('tweet_reset', 0):
|
||||||
if username not in rate_limit_info or current_time >= rate_limit_info.get(username, {}).get('tweet_reset', 0):
|
remaining = author_info.get('tweet_remaining', 0)
|
||||||
rate_limit_info[username] = {
|
reset = author_info.get('tweet_reset', current_time + tweet_window_seconds)
|
||||||
'tweet_remaining': 1, # Allow one tweet to avoid blocking
|
logger.warning(f"Test tweet failed for {username}, using last known quota: {remaining} remaining")
|
||||||
'tweet_reset': current_time + tweet_window_seconds
|
else:
|
||||||
}
|
remaining = max_tweets
|
||||||
save_json_file(rate_limit_file, rate_limit_info)
|
reset = current_time + tweet_window_seconds
|
||||||
remaining = rate_limit_info[username].get('tweet_remaining', 1)
|
logger.warning(f"Test tweet failed for {username}, resetting quota to {max_tweets}")
|
||||||
reset = rate_limit_info[username].get('tweet_reset', current_time + tweet_window_seconds)
|
# Update author info with synced quota
|
||||||
logger.warning(f"X API rate limit check failed for {username}, using fallback: {remaining} remaining")
|
author_info = {
|
||||||
check_author_rate_limit.cache[cache_key] = (remaining, reset)
|
'tweet_remaining': remaining,
|
||||||
|
'tweet_reset': reset,
|
||||||
|
'tweets_posted_in_run': 0,
|
||||||
|
'script_run_id': check_author_rate_limit.script_run_id
|
||||||
|
}
|
||||||
|
rate_limit_info[username] = author_info
|
||||||
|
save_json_file(rate_limit_file, rate_limit_info)
|
||||||
|
|
||||||
|
# Calculate remaining tweets based on tweets posted in this run
|
||||||
|
remaining = author_info['tweet_remaining'] - author_info['tweets_posted_in_run']
|
||||||
|
reset = author_info['tweet_reset']
|
||||||
|
|
||||||
|
# Check if quota has reset
|
||||||
|
if current_time >= reset:
|
||||||
|
logger.info(f"Quota reset for {username}, restoring to {max_tweets} tweets")
|
||||||
|
remaining = max_tweets
|
||||||
|
reset = current_time + tweet_window_seconds
|
||||||
|
author_info['tweet_remaining'] = remaining
|
||||||
|
author_info['tweet_reset'] = reset
|
||||||
|
author_info['tweets_posted_in_run'] = 0
|
||||||
|
rate_limit_info[username] = author_info
|
||||||
|
save_json_file(rate_limit_file, rate_limit_info)
|
||||||
|
|
||||||
can_post = remaining > 0
|
can_post = remaining > 0
|
||||||
if not can_post:
|
if not can_post:
|
||||||
reset_time = datetime.fromtimestamp(reset, tz=timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
|
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}")
|
logger.info(f"Author {username} quota exhausted. Remaining: {remaining}, Reset at: {reset_time}")
|
||||||
else:
|
else:
|
||||||
logger.info(f"Rate limit for {username}: {remaining}/{max_tweets} tweets remaining")
|
logger.info(f"Quota for {username}: {remaining}/{max_tweets} tweets remaining")
|
||||||
|
|
||||||
# Update rate_limit_info.json
|
|
||||||
rate_limit_info = load_json_file(rate_limit_file, default={})
|
|
||||||
rate_limit_info[username] = {'tweet_remaining': remaining, 'tweet_reset': reset}
|
|
||||||
save_json_file(rate_limit_file, rate_limit_info)
|
|
||||||
|
|
||||||
return can_post, remaining, reset
|
return can_post, remaining, reset
|
||||||
|
|
||||||
@@ -1245,9 +1280,8 @@ def get_next_author_round_robin():
|
|||||||
|
|
||||||
def get_x_rate_limit_status(author):
|
def get_x_rate_limit_status(author):
|
||||||
"""
|
"""
|
||||||
Check the X API v2 rate limit status for an author by attempting a test tweet.
|
Check the X API Free tier rate limit by posting a test tweet.
|
||||||
Returns (remaining, reset) where remaining is the number of tweets left in the 24-hour window,
|
Returns (remaining, reset) based on app-level headers (x-rate-limit-remaining, x-rate-limit-reset).
|
||||||
and reset is the Unix timestamp when the limit resets.
|
|
||||||
Returns (None, None) if the check fails.
|
Returns (None, None) if the check fails.
|
||||||
"""
|
"""
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -1270,12 +1304,12 @@ def get_x_rate_limit_status(author):
|
|||||||
response = requests.post(url, json=payload, auth=oauth)
|
response = requests.post(url, json=payload, auth=oauth)
|
||||||
headers = response.headers
|
headers = response.headers
|
||||||
|
|
||||||
# Extract rate limit info from headers
|
# Extract app-level rate limit info from headers
|
||||||
remaining = int(headers.get('x-user-limit-24hour-remaining', 0))
|
remaining = int(headers.get('x-rate-limit-remaining', 0))
|
||||||
reset = int(headers.get('x-user-limit-24hour-reset', 0))
|
reset = int(headers.get('x-rate-limit-reset', 0))
|
||||||
|
|
||||||
if response.status_code == 201:
|
if response.status_code == 201:
|
||||||
# Tweet posted successfully, delete it
|
# Delete the test tweet
|
||||||
tweet_id = response.json().get('data', {}).get('id')
|
tweet_id = response.json().get('data', {}).get('id')
|
||||||
if tweet_id:
|
if tweet_id:
|
||||||
delete_url = f'https://api.x.com/2/tweets/{tweet_id}'
|
delete_url = f'https://api.x.com/2/tweets/{tweet_id}'
|
||||||
@@ -1283,20 +1317,19 @@ def get_x_rate_limit_status(author):
|
|||||||
if delete_response.status_code == 200:
|
if delete_response.status_code == 200:
|
||||||
logger.info(f"Successfully deleted test tweet {tweet_id} for {username}")
|
logger.info(f"Successfully deleted test tweet {tweet_id} for {username}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Failed to delete test tweet {tweet_id} for {username}: {delete_response.status_code}")
|
logger.warning(f"Failed to delete test tweet {tweet_id} for {username}: {delete_response.status_code} - {delete_response.text}")
|
||||||
|
logger.info(f"Rate limit for {username}: {remaining} remaining, reset at {datetime.fromtimestamp(reset, tz=timezone.utc)}")
|
||||||
|
return remaining, reset
|
||||||
elif response.status_code == 429:
|
elif response.status_code == 429:
|
||||||
# Rate limit exceeded
|
|
||||||
logger.info(f"Rate limit exceeded for {username}: {remaining} remaining, reset at {datetime.fromtimestamp(reset, tz=timezone.utc)}")
|
logger.info(f"Rate limit exceeded for {username}: {remaining} remaining, reset at {datetime.fromtimestamp(reset, tz=timezone.utc)}")
|
||||||
|
return remaining, reset
|
||||||
elif response.status_code == 403:
|
elif response.status_code == 403:
|
||||||
# Forbidden (e.g., account restrictions), but headers may still provide rate limit info
|
|
||||||
logger.warning(f"403 Forbidden for {username}: {response.text}, rate limit info: {remaining} remaining, reset at {datetime.fromtimestamp(reset, tz=timezone.utc)}")
|
logger.warning(f"403 Forbidden for {username}: {response.text}, rate limit info: {remaining} remaining, reset at {datetime.fromtimestamp(reset, tz=timezone.utc)}")
|
||||||
|
return remaining, reset
|
||||||
else:
|
else:
|
||||||
logger.error(f"Unexpected response for {username}: {response.status_code} - {response.text}")
|
logger.error(f"Unexpected response for {username}: {response.status_code} - {response.text}")
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
logger.info(f"Rate limit for {username}: {remaining} remaining, reset at {datetime.fromtimestamp(reset, tz=timezone.utc)}")
|
|
||||||
return remaining, reset
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error fetching X rate limit for {username}: {e}", exc_info=True)
|
logger.error(f"Unexpected error fetching X rate limit for {username}: {e}", exc_info=True)
|
||||||
return None, None
|
return None, None
|
||||||
|
|||||||
Reference in New Issue
Block a user