From 447bfb0087079caba95a42a1e8baae44f3ffcb6e Mon Sep 17 00:00:00 2001
From: Shane
Date: Thu, 8 May 2025 16:27:10 +1000
Subject: [PATCH] update real time rate limiting checks for X
---
check_rate_limits.py | 125 ++++++++++++++++++
foodie_automator_google.py | 14 +-
foodie_automator_reddit.py | 14 +-
foodie_automator_rss.py | 30 ++---
foodie_engagement_tweet.py | 16 +--
foodie_utils.py | 263 +++++++++++++++----------------------
foodie_weekly_thread.py | 21 ++-
7 files changed, 286 insertions(+), 197 deletions(-)
create mode 100644 check_rate_limits.py
diff --git a/check_rate_limits.py b/check_rate_limits.py
new file mode 100644
index 0000000..a6a46fe
--- /dev/null
+++ b/check_rate_limits.py
@@ -0,0 +1,125 @@
+import requests
+from requests_oauthlib import OAuth1
+import logging
+from datetime import datetime, timezone
+from dotenv import load_dotenv
+import os
+import time
+from foodie_config import X_API_CREDENTIALS
+
+# Load environment variables from .env file
+load_dotenv()
+
+# Set up logging
+logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
+
+# Function to delete a tweet
+def delete_tweet(tweet_id, auth):
+ try:
+ response = requests.delete(f"https://api.x.com/2/tweets/{tweet_id}", auth=auth)
+ response.raise_for_status()
+ logging.info(f"Successfully deleted tweet {tweet_id}")
+ return True
+ except Exception as e:
+ logging.error(f"Failed to delete tweet {tweet_id}: {e}")
+ return False
+
+# Function to check rate limits for a given author
+def check_rate_limits_for_author(username, credentials, retry=False):
+ logging.info(f"{'Retrying' if retry else 'Checking'} rate limits for {username} (handle: {credentials['x_username']})")
+
+ # Retrieve OAuth 1.0a credentials for the author
+ consumer_key = credentials["api_key"]
+ consumer_secret = credentials["api_secret"]
+ access_token = credentials["access_token"]
+ access_token_secret = credentials["access_token_secret"]
+
+ # Validate credentials
+ if not all([consumer_key, consumer_secret, access_token, access_token_secret]):
+ logging.error(f"Missing OAuth credentials for {username} in X_API_CREDENTIALS.")
+ return None
+
+ # Set up OAuth 1.0a authentication
+ auth = OAuth1(consumer_key, consumer_secret, access_token, access_token_secret)
+
+ # Add delay to avoid IP-based rate limiting
+ logging.info(f"Waiting 5 seconds before attempting to post for {username}")
+ time.sleep(5)
+
+ # Try posting a test tweet to get v2 rate limit headers
+ tweet_id = None
+ try:
+ tweet_data = {"text": f"Test tweet to check rate limits for {username} - please ignore"}
+ response = requests.post("https://api.x.com/2/tweets", json=tweet_data, auth=auth)
+ response.raise_for_status()
+ tweet_id = response.json()['data']['id']
+ logging.info("Successfully posted test tweet for %s: %s", username, response.json())
+ logging.info("Response Headers for %s: %s", username, response.headers)
+ # Extract rate limit headers if present
+ app_limit = response.headers.get('x-app-limit-24hour-limit', 'N/A')
+ app_remaining = response.headers.get('x-app-limit-24hour-remaining', 'N/A')
+ app_reset = response.headers.get('x-app-limit-24hour-reset', 'N/A')
+ logging.info("App 24-Hour Tweet Limit for %s: %s", username, app_limit)
+ logging.info("App 24-Hour Tweets Remaining for %s: %s", username, app_remaining)
+ if app_reset != 'N/A':
+ reset_time = datetime.fromtimestamp(int(app_reset), timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')
+ logging.info("App 24-Hour Reset (Readable) for %s: %s", username, reset_time)
+ return tweet_id
+ except requests.exceptions.HTTPError as e:
+ logging.info("Test Tweet Response Status Code for %s: %s", username, e.response.status_code)
+ logging.info("Test Tweet Response Headers for %s: %s", username, e.response.headers)
+ if e.response.status_code == 429:
+ logging.info("Rate Limit Exceeded for /2/tweets for %s", username)
+
+ # Extract user-specific 24-hour limits
+ user_limit = e.response.headers.get('x-user-limit-24hour-limit', 'N/A')
+ user_remaining = e.response.headers.get('x-user-limit-24hour-remaining', 'N/A')
+ user_reset = e.response.headers.get('x-user-limit-24hour-reset', 'N/A')
+ logging.info("User 24-Hour Tweet Limit for %s: %s", username, user_limit)
+ logging.info("User 24-Hour Tweets Remaining for %s: %s", username, user_remaining)
+ logging.info("User 24-Hour Reset (Timestamp) for %s: %s", username, user_reset)
+ if user_reset != 'N/A':
+ reset_time = datetime.fromtimestamp(int(user_reset), timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')
+ logging.info("User 24-Hour Reset (Readable) for %s: %s", username, reset_time)
+
+ # Extract app-specific 24-hour limits
+ app_limit = e.response.headers.get('x-app-limit-24hour-limit', 'N/A')
+ app_remaining = e.response.headers.get('x-app-limit-24hour-remaining', 'N/A')
+ app_reset = e.response.headers.get('x-app-limit-24hour-reset', 'N/A')
+ logging.info("App 24-Hour Tweet Limit for %s: %s", username, app_limit)
+ logging.info("App 24-Hour Tweets Remaining for %s: %s", username, app_remaining)
+ logging.info("App 24-Hour Reset (Timestamp) for %s: %s", username, app_reset)
+ if app_reset != 'N/A':
+ reset_time = datetime.fromtimestamp(int(app_reset), timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')
+ logging.info("App 24-Hour Reset (Readable) for %s: %s", username, reset_time)
+ return None
+ except Exception as e:
+ logging.error("Failed to post test tweet for %s: %s", username, e)
+ return None
+
+# Main loop to check rate limits for all authors
+if __name__ == "__main__":
+ # First pass: Attempt to post for all authors
+ successful_tweets = {}
+ for username, credentials in X_API_CREDENTIALS.items():
+ tweet_id = check_rate_limits_for_author(username, credentials)
+ if tweet_id:
+ successful_tweets[username] = (tweet_id, credentials)
+ logging.info("-" * 50)
+
+ # Delete successful tweets to free up quota
+ for username, (tweet_id, credentials) in successful_tweets.items():
+ auth = OAuth1(
+ credentials["api_key"],
+ credentials["api_secret"],
+ credentials["access_token"],
+ credentials["access_token_secret"]
+ )
+ delete_tweet(tweet_id, auth)
+
+ # Second pass: Retry for authors that failed
+ logging.info("Retrying for authors that initially failed...")
+ for username, credentials in X_API_CREDENTIALS.items():
+ if username not in successful_tweets:
+ check_rate_limits_for_author(username, credentials, retry=True)
+ logging.info("-" * 50)
\ No newline at end of file
diff --git a/foodie_automator_google.py b/foodie_automator_google.py
index 822fe5d..8d8a407 100644
--- a/foodie_automator_google.py
+++ b/foodie_automator_google.py
@@ -272,6 +272,16 @@ def curate_from_google_trends(posted_titles_data, posted_titles, used_images_dat
attempts += 1
continue
+ # Check author availability before GPT calls
+ author = get_next_author_round_robin()
+ if not author:
+ logging.info(f"Skipping trend '{title}' due to tweet rate limits for all authors")
+ attempts += 1
+ continue
+
+ author_username = author["username"]
+ logging.info(f"Selected author via round-robin: {author_username}")
+
logging.info(f"Trying Google Trend: {title} from {source_name}")
try:
@@ -319,10 +329,6 @@ def curate_from_google_trends(posted_titles_data, posted_titles, used_images_dat
final_summary = insert_link_naturally(final_summary, source_name, link)
- author = get_next_author_round_robin()
- author_username = author["username"]
- logging.info(f"Selected author via round-robin: {author_username}")
-
post_data = {
"title": generate_title_from_summary(final_summary),
"content": final_summary,
diff --git a/foodie_automator_reddit.py b/foodie_automator_reddit.py
index a619739..0891509 100644
--- a/foodie_automator_reddit.py
+++ b/foodie_automator_reddit.py
@@ -292,6 +292,16 @@ def curate_from_reddit(posted_titles_data, posted_titles, used_images_data, used
attempts += 1
continue
+ # Check author availability before GPT calls
+ author = get_next_author_round_robin()
+ if not author:
+ logging.info(f"Skipping post '{title}' due to tweet rate limits for all authors")
+ attempts += 1
+ continue
+
+ author_username = author["username"]
+ logging.info(f"Selected author via round-robin: {author_username}")
+
logging.info(f"Trying Reddit Post: {title} from {source_name}")
try:
@@ -339,10 +349,6 @@ def curate_from_reddit(posted_titles_data, posted_titles, used_images_data, used
final_summary = insert_link_naturally(final_summary, source_name, link)
- author = get_next_author_round_robin()
- author_username = author["username"]
- logging.info(f"Selected author via round-robin: {author_username}")
-
post_data = {
"title": generate_title_from_summary(final_summary),
"content": final_summary,
diff --git a/foodie_automator_rss.py b/foodie_automator_rss.py
index 05643c7..7fb3f41 100644
--- a/foodie_automator_rss.py
+++ b/foodie_automator_rss.py
@@ -253,15 +253,9 @@ def fetch_duckduckgo_news_context(title, hours=24):
logging.error(f"Failed to fetch DuckDuckGo News context for '{title}' after {MAX_RETRIES} attempts")
return title
-def curate_from_rss():
+def curate_from_rss(posted_titles_data, posted_titles, used_images_data, used_images):
try:
- global posted_titles_data, posted_titles, used_images
- # Load JSON files once
- posted_titles_data = load_json_file(POSTED_TITLES_FILE, EXPIRATION_HOURS)
- posted_titles = set(entry["title"] for entry in posted_titles_data)
- used_images_data = load_json_file(USED_IMAGES_FILE, IMAGE_EXPIRATION_DAYS)
- used_images = set(entry["title"] for entry in used_images_data if "title" in entry)
- logging.debug(f"Loaded {len(posted_titles)} posted titles and {len(used_images)} used images")
+ logging.debug(f"Using {len(posted_titles)} posted titles and {len(used_images)} used images")
articles = fetch_rss_feeds()
if not articles:
@@ -283,6 +277,16 @@ def curate_from_rss():
attempts += 1
continue
+ # Check author availability before GPT calls
+ author = get_next_author_round_robin()
+ if not author:
+ logging.info(f"Skipping article '{title}' due to tweet rate limits for all authors")
+ attempts += 1
+ continue
+
+ author_username = author["username"]
+ logging.info(f"Selected author via round-robin: {author_username}")
+
logging.info(f"Trying RSS Article: {title} from {source_name}")
try:
@@ -330,11 +334,6 @@ def curate_from_rss():
final_summary = insert_link_naturally(final_summary, source_name, link)
- # Select author
- author = get_next_author_round_robin()
- author_username = author["username"]
- logging.info(f"Selected author via round-robin: {author_username}")
-
post_data = {
"title": generate_title_from_summary(final_summary),
"content": final_summary,
@@ -362,8 +361,6 @@ def curate_from_rss():
f' '
f'
'
)
-
- # Prepare post content with share links placeholder
post_data["content"] = f"{final_summary}\n\n{share_links_template}"
global is_posting
@@ -426,9 +423,6 @@ def curate_from_rss():
logging.info(f"***** SUCCESS: Posted '{post_data['title']}' (ID: {post_id}) from RSS *****")
return post_data, category, random.randint(0, 1800)
- attempts += 1
- logging.info(f"WP posting failed for '{post_data['title']}'")
-
logging.info("No interesting RSS article found after attempts")
return None, None, random.randint(600, 1800)
except Exception as e:
diff --git a/foodie_engagement_tweet.py b/foodie_engagement_tweet.py
index 744244b..fd875b3 100644
--- a/foodie_engagement_tweet.py
+++ b/foodie_engagement_tweet.py
@@ -159,14 +159,17 @@ def post_engagement_tweet():
"""Post engagement tweets for authors daily."""
try:
logging.info("Starting foodie_engagement_tweet.py")
- print("Starting foodie_engagement_tweet.py")
for author in AUTHORS:
# Check if the author can post before generating the tweet
- can_post, remaining, reset = check_author_rate_limit(author)
- if not can_post:
- reset_time = time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(reset)) if reset else "Unknown"
- logging.info(f"Skipping engagement tweet for {author['username']} due to rate limit. Remaining: {remaining}, Reset at: {reset_time}")
+ if check_author_rate_limit(author):
+ reset_time = datetime.fromtimestamp(
+ load_json_file('/home/shane/foodie_automator/rate_limit_info.json', default={})
+ .get(author['username'], {})
+ .get('tweet_reset', time.time()),
+ tz=timezone.utc
+ ).strftime('%Y-%m-%d %H:%M:%S')
+ logging.info(f"Skipping engagement tweet for {author['username']} due to rate limit. Reset at: {reset_time}")
continue
try:
@@ -176,7 +179,6 @@ def post_engagement_tweet():
continue
logging.info(f"Posting engagement tweet for {author['username']}: {tweet}")
- print(f"Posting engagement tweet for {author['username']}: {tweet}")
if post_tweet(author, tweet):
logging.info(f"Successfully posted engagement tweet for {author['username']}")
else:
@@ -186,10 +188,8 @@ def post_engagement_tweet():
continue
logging.info("Completed foodie_engagement_tweet.py")
- print("Completed foodie_engagement_tweet.py")
except Exception as e:
logging.error(f"Unexpected error in post_engagement_tweet: {e}", exc_info=True)
- print(f"Error in post_engagement_tweet: {e}")
def main():
"""Main function to run the script."""
diff --git a/foodie_utils.py b/foodie_utils.py
index 4b1b7d1..787aa70 100644
--- a/foodie_utils.py
+++ b/foodie_utils.py
@@ -162,6 +162,10 @@ def generate_article_tweet(author, post, persona):
return tweet
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.
+ """
from foodie_config import X_API_CREDENTIALS
import logging
import tweepy
@@ -177,6 +181,16 @@ def post_tweet(author, tweet, reply_to_id=None):
if reply_to_id:
logging.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"],
@@ -188,15 +202,32 @@ def post_tweet(author, tweet, reply_to_id=None):
text=tweet,
in_reply_to_tweet_id=reply_to_id
)
- logging.info(f"Posted tweet for {author['username']} (handle: {credentials['x_username']}): {tweet}")
- logging.debug(f"Tweet ID: {response.data['id']}")
- return {"id": response.data["id"]}
+ tweet_id = response.data['id']
+ logging.info(f"Successfully posted tweet {tweet_id} for {author['username']} (handle: {credentials['x_username']}): {tweet}")
+
+ # 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}")
- if hasattr(e, 'response') and e.response:
- logging.error(f"Twitter API response: {e.response.text}")
- if "forbidden" in str(e).lower():
- logging.error(f"Possible causes: invalid credentials, insufficient permissions, or account restrictions for {credentials['x_username']}")
+ 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
+ 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)}")
return False
except Exception as e:
logging.error(f"Unexpected error posting tweet for {author['username']} (handle: {credentials['x_username']}): {e}", exc_info=True)
@@ -681,141 +712,64 @@ 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="Pixabay", uploader=None, page_url=None, interest_score=4, post_id=None, should_post_tweet=True):
- wp_base_url = "https://insiderfoodie.com/wp-json/wp/v2"
- logging.info(f"Starting post_to_wp for '{post_data['title']}', image_source: {image_source}")
+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):
+ """
+ Post or update content to WordPress, optionally tweeting the post.
+ """
+ import logging
+ import requests
+ from foodie_config import WP_CREDENTIALS, X_API_CREDENTIALS
+
+ logger = logging.getLogger(__name__)
+ wp_username = WP_CREDENTIALS["username"]
+ wp_password = WP_CREDENTIALS["password"]
- if not isinstance(author, dict) or "username" not in author or "password" not in author:
- raise ValueError(f"Invalid author data: {author}. Expected a dictionary with 'username' and 'password' keys.")
+ endpoint = f"{WP_CREDENTIALS['url']}/wp-json/wp/v2/posts"
+ if post_id:
+ endpoint += f"/{post_id}"
- wp_username = author["username"]
- wp_password = author["password"]
+ headers = {
+ "Authorization": "Basic " + base64.b64encode(f"{wp_username}:{wp_password}".encode()).decode(),
+ "Content-Type": "application/json"
+ }
- if not isinstance(interest_score, int):
- logging.error(f"Invalid interest_score type: {type(interest_score)}, value: '{interest_score}'. Defaulting to 4.")
- interest_score = 4
- elif interest_score < 0 or interest_score > 10:
- logging.warning(f"interest_score out of valid range (0-10): {interest_score}. Clamping to 4.")
- interest_score = min(max(interest_score, 0), 10)
+ payload = {
+ "title": post_data["title"],
+ "content": post_data["content"],
+ "status": post_data["status"],
+ "author": WP_CREDENTIALS["authors"].get(post_data["author"], 1),
+ "categories": [category]
+ }
try:
- headers = {
- "Authorization": f"Basic {base64.b64encode(f'{wp_username}:{wp_password}'.encode()).decode()}",
- "Content-Type": "application/json"
- }
-
- auth_test = requests.get(f"{wp_base_url}/users/me", headers=headers)
- auth_test.raise_for_status()
- logging.info(f"Auth test passed for {wp_username}: {auth_test.json()['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)
- logging.info(f"Created new category '{category}' with ID {category_id}")
- else:
- logging.info(f"Found existing category '{category}' with ID {category_id}")
-
- tags = [1]
- 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)
- logging.info(f"Added 'Picks' tag (ID: {picks_tag_id}) to post due to high interest score: {interest_score}")
-
- content = post_data["content"]
- if content is None:
- logging.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"{para}
" for para in content.split('\n') if para.strip())
-
- author_id_map = {
- "owenjohnson": 10,
- "javiermorales": 2,
- "aishapatel": 3,
- "trangnguyen": 12,
- "keishareid": 13,
- "lilamoreau": 7
- }
- author_id = author_id_map.get(author["username"], 5)
-
- image_id = None
- if image_url:
- logging.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:
- logging.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:
- logging.warning(f"All image uploads failed for '{post_data['title']}' - posting without image")
-
- payload = {
- "title": post_data["title"],
- "content": formatted_content,
- "status": "publish",
- "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
- logging.info(f"Set featured image for post '{post_data['title']}': Media ID={image_id}")
-
- endpoint = f"{wp_base_url}/posts/{post_id}" if post_id else f"{wp_base_url}/posts"
- method = requests.post
-
- logging.debug(f"Sending WP request to {endpoint} with payload: {json.dumps(payload, indent=2)}")
-
- response = method(endpoint, headers=headers, json=payload)
+ response = requests.post(endpoint, headers=headers, json=payload)
response.raise_for_status()
-
- post_info = response.json()
- logging.debug(f"WP response: {json.dumps(post_info, indent=2)}")
-
- 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"]
-
- # Save to recent_posts.json only on initial post, not updates
- if not post_id:
- timestamp = datetime.now(timezone.utc).isoformat()
- save_post_to_recent(post_data["title"], post_url, author["username"], timestamp)
-
- if should_post_tweet:
- try:
- post = {"title": post_data["title"], "url": post_url}
- tweet = generate_article_tweet(author, post, author["persona"])
- if post_tweet(author, tweet):
- logging.info(f"Successfully posted article tweet for {author['username']} on X")
+ post_id = response.json().get("id")
+ post_url = response.json().get("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"], image_source, uploader, page_url)
+ if media_id:
+ requests.post(
+ f"{WP_CREDENTIALS['url']}/wp-json/wp/v2/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:
+ 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
+ logger.info(f"Successfully tweeted for post: {post_data['title']}")
else:
- logging.warning(f"Failed to post article tweet for {author['username']} on X")
- except Exception as e:
- logging.error(f"Error posting article tweet for {author['username']}: {e}")
-
- logging.info(f"Posted/Updated by {author['username']}: {post_data['title']} (ID: {post_id})")
+ logger.warning(f"Failed to tweet for post: {post_data['title']}")
+
return post_id, post_url
-
except requests.exceptions.RequestException as e:
- logging.error(f"WP API request failed: {e} - Response: {e.response.text if e.response else 'No response'}")
- print(f"WP Error: {e}")
- return None, None
- except KeyError as e:
- logging.error(f"WP payload error - Missing key: {e} - Author data: {author}")
- print(f"WP Error: {e}")
- return None, None
- except Exception as e:
- logging.error(f"WP posting failed: {e}")
- print(f"WP Error: {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
@@ -1125,46 +1079,44 @@ def check_rate_limit(response):
logging.warning(f"Failed to parse rate limit headers: {e}")
return None, None
-def check_author_rate_limit(author, max_requests=10, window_seconds=3600):
+def check_author_rate_limit(author, max_tweets=17, tweet_window_seconds=86400):
"""
- Check if an author is rate-limited.
+ Check if an author is rate-limited for tweets based on X API limits.
"""
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={})
username = author['username']
- if username not in rate_limit_info or not isinstance(rate_limit_info[username].get('reset'), (int, float)):
+ if username not in rate_limit_info or not isinstance(rate_limit_info[username].get('tweet_reset'), (int, float)):
rate_limit_info[username] = {
- 'remaining': max_requests,
- 'reset': time.time()
+ 'tweet_remaining': max_tweets,
+ 'tweet_reset': time.time()
}
- logger.info(f"Initialized rate limit for {username}: {max_requests} requests available")
+ logger.info(f"Initialized tweet rate limit for {username}: {max_tweets} tweets available")
info = rate_limit_info[username]
current_time = time.time()
- # Reset if window expired or timestamp is invalid (e.g., 1970)
- if current_time >= info['reset'] or info['reset'] < 1000000000: # 1000000000 is ~2001
- info['remaining'] = max_requests
- info['reset'] = current_time + window_seconds
- logger.info(f"Reset rate limit for {username}: {max_requests} requests available")
+ # 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 info['remaining'] <= 0:
- reset_time = datetime.fromtimestamp(info['reset'], tz=timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
- logger.info(f"Author {username} is rate-limited. Remaining: {info['remaining']}, Reset at: {reset_time}")
+ 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
- # Decrement remaining requests
- info['remaining'] -= 1
- save_json_file(rate_limit_file, rate_limit_info)
- logger.info(f"Updated rate limit for {username}: {info['remaining']} requests remaining")
+ logger.info(f"Tweet rate limit for {username}: {info['tweet_remaining']} tweets remaining")
return False
def get_next_author_round_robin():
"""
- Select the next author using round-robin, respecting rate limits.
+ Select the next author using round-robin, respecting tweet rate limits.
+ Returns None if no author is available.
"""
from foodie_config import AUTHORS
global round_robin_index
@@ -1178,11 +1130,8 @@ def get_next_author_round_robin():
logger.info(f"Selected author via round-robin: {author['username']}")
return author
- logger.warning("No authors available due to rate limits. Selecting a random author as fallback.")
- import random
- author = random.choice(AUTHORS)
- logger.info(f"Selected author via random fallback: {author['username']}")
- return author
+ logger.warning("No authors available due to tweet rate limits.")
+ return None
def prepare_post_data(summary, title, main_topic=None):
try:
diff --git a/foodie_weekly_thread.py b/foodie_weekly_thread.py
index a95f237..9bed8f1 100644
--- a/foodie_weekly_thread.py
+++ b/foodie_weekly_thread.py
@@ -319,10 +319,14 @@ def post_weekly_thread():
continue
# Check if the author can post before generating the thread
- can_post, remaining, reset = check_author_rate_limit(author)
- if not can_post:
- reset_time = time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(reset)) if reset else "Unknown"
- logging.info(f"Skipping weekly thread for {username} due to rate limit. Remaining: {remaining}, Reset at: {reset_time}")
+ if check_author_rate_limit(author):
+ reset_time = datetime.fromtimestamp(
+ load_json_file('/home/shane/foodie_automator/rate_limit_info.json', default={})
+ .get(username, {})
+ .get('tweet_reset', time.time()),
+ tz=timezone.utc
+ ).strftime('%Y-%m-%d %H:%M:%S')
+ logging.info(f"Skipping weekly thread for {username} due to rate limit. Reset at: {reset_time}")
continue
# Select top 2 posts (to fit within 3-tweet limit: lead + 2 posts)
@@ -359,12 +363,17 @@ def post_weekly_thread():
# Post final CTA tweet
final_cta = generate_final_cta(author)
if final_cta:
- post_tweet(author, final_cta, reply_to_id=lead_tweet_id)
- logging.info(f"Posted final CTA tweet for {username}: {final_cta}")
+ cta_response = post_tweet(author, final_cta, reply_to_id=lead_tweet_id)
+ if cta_response:
+ logging.info(f"Posted final CTA tweet for {username}: {final_cta}")
+ else:
+ logging.warning(f"Failed to post final CTA tweet for {username}")
except Exception as e:
logging.error(f"Error posting thread for {username}: {e}", exc_info=True)
continue
+ logging.info("Completed foodie_weekly_thread.py")
+
def main():
"""Main function to run the script."""
lock_fd = None