From 94ef0294bf1d5b415e5d2ff617e88509571d99fb Mon Sep 17 00:00:00 2001 From: Shane Date: Sat, 26 Apr 2025 13:19:52 +1000 Subject: [PATCH] update posting to X authors --- foodie_config.py | 43 +++++--- foodie_utils.py | 42 +++++++- foodie_x_config.py | 99 ++++++++++++++++++ foodie_x_poster.py | 223 ++++++++++++++++++++++++++++++++++++++++ generate_backgrounds.py | 55 ++++++++++ requirements.txt | 4 +- 6 files changed, 447 insertions(+), 19 deletions(-) create mode 100644 foodie_x_config.py create mode 100644 foodie_x_poster.py create mode 100644 generate_backgrounds.py diff --git a/foodie_config.py b/foodie_config.py index d6a4a5a..bddc936 100644 --- a/foodie_config.py +++ b/foodie_config.py @@ -1,51 +1,60 @@ # foodie_config.py # Constants shared across all automator scripts +from dotenv import load_dotenv +import os -OPENAI_API_KEY = "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" -PIXABAY_API_KEY = "14836528-999c19a033d77d463113b1fb8" +load_dotenv() +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +PIXABAY_API_KEY = os.getenv("PIXABAY_API_KEY") AUTHORS = [ { "url": "https://insiderfoodie.com", "username": "owenjohnson", - "password": "hESy E2qO grdB KiKS X6SW oM05", + "password": os.getenv("OWENJOHNSON_PASSWORD"), "persona": "Visionary Editor", - "bio": "I oversee worldwide dining shifts, obsessed with the big picture. My edits deliver precise takes—charting the future of food with confidence." + "bio": "I oversee worldwide dining shifts, obsessed with the big picture. My edits deliver precise takes—charting the future of food with confidence.", + "dob": "1990-04-26" }, { "url": "https://insiderfoodie.com", "username": "javiermorales", - "password": "r46q z0JX QL1q ztbH Tifk Cn28", + "password": os.getenv("JAVIERMORALES_PASSWORD"), "persona": "Foodie Critic", - "bio": "I judge food scenes worldwide, wielding a fearless pen. My takes expose what shines and what flops—no compromise, just truth." + "bio": "I judge food scenes worldwide, wielding a fearless pen. My takes expose what shines and what flops—no compromise, just truth.", + "dob": "1996-07-08" }, { "url": "https://insiderfoodie.com", "username": "aishapatel", - "password": "NyCa SOXd 5EVf bVvW KIoz wC0C", + "password": os.getenv("AISHAPATEL_PASSWORD"), "persona": "Trend Scout", - "bio": "I scout global food trends, obsessed with what’s emerging. My sharp predictions map the industry’s path—always one step ahead." + "bio": "I scout global food trends, obsessed with what’s emerging. My sharp predictions map the industry’s path—always one step ahead.", + "dob": "1999-03-15" }, { "url": "https://insiderfoodie.com", "username": "trangnguyen", - "password": "A53T Nn8i CCEI HMq8 a9Ps Uhyg", + "password": os.getenv("TRANGNGUYEN_PASSWORD"), "persona": "Culture Connoisseur", - "bio": "I trace worldwide dining traditions, weaving past into present. My words uncover the soul of flavor—connecting cultures bite by bite." + "bio": "I trace worldwide dining traditions, weaving past into present. My words uncover the soul of flavor—connecting cultures bite by bite.", + "dob": "2002-08-22" }, { "url": "https://insiderfoodie.com", "username": "keishareid", - "password": "BOGQ pjT8 rdTv JyOJ 3IjB Apww", + "password": os.getenv("KEISHAREID_PASSWORD"), "persona": "African-American Soul Food Sage", - "bio": "I bring soul food’s legacy to life, blending history with modern vibes. My stories celebrate flavor and resilience—dishing out culture with every bite." + "bio": "I bring soul food’s legacy to life, blending history with modern vibes. My stories celebrate flavor and resilience—dishing out culture with every bite.", + "dob": "1994-06-10" }, { "url": "https://insiderfoodie.com", "username": "lilamoreau", - "password": "e3nv Vsg4 L9wv RgL6 dHkm T3UD", + "password": os.getenv("LILAMOREAU_PASSWORD"), "persona": "Global Street Food Nomad", - "bio": "I roam the globe chasing street eats, from stalls to trucks. My tales uncover bold flavors and gritty trends shaping food on the go." + "bio": "I roam the globe chasing street eats, from stalls to trucks. My tales uncover bold flavors and gritty trends shaping food on the go.", + "dob": "1993-02-14" } ] @@ -133,9 +142,9 @@ SUMMARY_PERSONA_PROMPTS = { ) } -REDDIT_CLIENT_ID = "GtoZmrM8VyrxMvb7gBLrLg" -REDDIT_CLIENT_SECRET = "YGTx69ZzvMn329pZj2qiEEXW82aeSA" -REDDIT_USER_AGENT = "foodie_trends_bot by /u/AskShaneHill" +REDDIT_CLIENT_ID = os.getenv("REDDIT_CLIENT_ID") +REDDIT_CLIENT_SECRET = os.getenv("REDDIT_CLIENT_SECRET") +REDDIT_USER_AGENT = os.getenv("REDDIT_USER_AGENT") REDDIT_SUBREDDITS = [ "food", "FoodPorn", diff --git a/foodie_utils.py b/foodie_utils.py index 9343f06..ad7dc7e 100644 --- a/foodie_utils.py +++ b/foodie_utils.py @@ -761,6 +761,13 @@ def post_to_wp(post_data, category, link, author, image_url, original_source, im post_id = post_info["id"] post_url = post_info["link"] + + # Save to recent_posts.json + timestamp = datetime.now(timezone.utc).isoformat() + save_post_to_recent(post_data["title"], post_url, author["username"], timestamp) + + logging.info(f"Posted/Updated by {author['username']}: {post_data['title']} (ID: {post_id})") + return post_id, post_url logging.info(f"Posted/Updated by {author['username']}: {post_data['title']} (ID: {post_id})") return post_id, post_url @@ -952,4 +959,37 @@ def prepare_post_data(final_summary, original_title, context_info=""): author = {"username": "owenjohnson", "password": "rfjk xhn6 2RPy FuQ9 cGlU K8mC"} category = generate_category_from_summary(final_summary) - return post_data, author, category, image_url, image_source, uploader, page_url \ No newline at end of file + return post_data, author, category, image_url, image_source, uploader, page_url + +def save_post_to_recent(post_title, post_url, author_username, timestamp): + """Save post details to recent_posts.json.""" + try: + recent_posts = load_json_file('/home/shane/foodie_automator/recent_posts.json') + entry = { + "title": post_title, + "url": post_url, + "author_username": author_username, + "timestamp": timestamp + } + recent_posts.append(entry) + with open('/home/shane/foodie_automator/recent_posts.json', 'w') as f: + for item in recent_posts: + json.dump(item, f) + f.write('\n') + logging.info(f"Saved post '{post_title}' to recent_posts.json") + except Exception as e: + logging.error(f"Failed to save post to recent_posts.json: {e}") + +def prune_recent_posts(): + """Prune recent_posts.json to keep only entries from the last 24 hours.""" + try: + cutoff = (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat() + recent_posts = load_json_file('/home/shane/foodie_automator/recent_posts.json') + recent_posts = [entry for entry in recent_posts if entry["timestamp"] > cutoff] + with open('/home/shane/foodie_automator/recent_posts.json', 'w') as f: + for item in recent_posts: + json.dump(item, f) + f.write('\n') + logging.info(f"Pruned recent_posts.json to {len(recent_posts)} entries") + except Exception as e: + logging.error(f"Failed to prune recent_posts.json: {e}") \ No newline at end of file diff --git a/foodie_x_config.py b/foodie_x_config.py new file mode 100644 index 0000000..335b575 --- /dev/null +++ b/foodie_x_config.py @@ -0,0 +1,99 @@ +# foodie_x_config.py +from dotenv import load_dotenv +import os + +load_dotenv() + +X_API_CREDENTIALS = [ + { + "username": "owenjohnson", + "x_username": "@insiderfoodieowen", + "api_key": os.getenv("OWENJOHNSON_X_API_KEY"), + "api_secret": os.getenv("OWENJOHNSON_X_API_SECRET"), + "access_token": os.getenv("OWENJOHNSON_X_ACCESS_TOKEN"), + "access_token_secret": os.getenv("OWENJOHNSON_X_ACCESS_TOKEN_SECRET"), + "client_secret": os.getenv("OWENJOHNSON_X_CLIENT_SECRET") + }, + { + "username": "javiermorales", + "x_username": "@insiderfoodiejavier", + "api_key": os.getenv("JAVIERMORALES_X_API_KEY"), + "api_secret": os.getenv("JAVIERMORALES_X_API_SECRET"), + "access_token": os.getenv("JAVIERMORALES_X_ACCESS_TOKEN"), + "access_token_secret": os.getenv("JAVIERMORALES_X_ACCESS_TOKEN_SECRET"), + "client_secret": os.getenv("JAVIERMORALES_X_CLIENT_SECRET") + }, + { + "username": "aishapatel", + "x_username": "@insiderfoodieaisha", + "api_key": os.getenv("AISHAPATEL_X_API_KEY"), + "api_secret": os.getenv("AISHAPATEL_X_API_SECRET"), + "access_token": os.getenv("AISHAPATEL_X_ACCESS_TOKEN"), + "access_token_secret": os.getenv("AISHAPATEL_X_ACCESS_TOKEN_SECRET"), + "client_secret": os.getenv("AISHAPATEL_X_CLIENT_SECRET") + }, + { + "username": "trangnguyen", + "x_username": "@insiderfoodietrang", + "api_key": os.getenv("TRANGNGUYEN_X_API_KEY"), + "api_secret": os.getenv("TRANGNGUYEN_X_API_SECRET"), + "access_token": os.getenv("TRANGNGUYEN_X_ACCESS_TOKEN"), + "access_token_secret": os.getenv("TRANGNGUYEN_X_ACCESS_TOKEN_SECRET"), + "client_secret": os.getenv("TRANGNGUYEN_X_CLIENT_SECRET") + }, + { + "username": "keishareid", + "x_username": "@insiderfoodiekeisha", + "api_key": os.getenv("KEISHAREID_X_API_KEY"), + "api_secret": os.getenv("KEISHAREID_X_API_SECRET"), + "access_token": os.getenv("KEISHAREID_X_ACCESS_TOKEN"), + "access_token_secret": os.getenv("KEISHAREID_X_ACCESS_TOKEN_SECRET"), + "client_secret": os.getenv("KEISHAREID_X_CLIENT_SECRET") + }, + { + "username": "lilamoreau", + "x_username": "@insiderfoodielila", + "api_key": os.getenv("LILAMOREAU_X_API_KEY"), + "api_secret": os.getenv("LILAMOREAU_X_API_SECRET"), + "access_token": os.getenv("LILAMOREAU_X_ACCESS_TOKEN"), + "access_token_secret": os.getenv("LILAMOREAU_X_ACCESS_TOKEN_SECRET"), + "client_secret": os.getenv("LILAMOREAU_X_CLIENT_SECRET") + } +] + +X_PERSONA_PROMPTS = { + "Visionary Editor": ( + "Craft a tweet as a commanding food editor with a bold, global view. Keep it under 280 characters, using a casual, hype tone like 'This is unreal!'. " + "For article tweets, include the article title, a quirky hook, and the URL. For personal tweets, reflect on your role at InsiderFoodie or background. " + "Avoid emojis and clichés like 'game-changer'. Return only the tweet text." + ), + "Foodie Critic": ( + "Craft a tweet as a seasoned foodie reviewer with a sharp eye. Keep it under 280 characters, using a pro yet lively tone like 'This bangs!'. " + "For article tweets, include the article title, a quirky hook, and the URL. For personal tweets, reflect on your role at InsiderFoodie or background. " + "Avoid emojis and clichés like 'game-changer'. Return only the tweet text." + ), + "Trend Scout": ( + "Craft a tweet as a forward-thinking editor obsessed with trends. Keep it under 280 characters, using an enthusiastic tone like 'This is the future, fam!'. " + "For article tweets, include the article title, a quirky hook, and the URL. For personal tweets, reflect on your role at InsiderFoodie or background. " + "Avoid emojis and clichés like 'game-changer'. Return only the tweet text." + ), + "Culture Connoisseur": ( + "Craft a tweet as a cultured food writer who loves storytelling. Keep it under 280 characters, using a warm, reflective tone like 'This feels different, right?'. " + "For article tweets, include the article title, a quirky hook, and the URL. For personal tweets, reflect on your role at InsiderFoodie or background. " + "Avoid emojis and clichés like 'game-changer'. Return only the tweet text." + ), + "African-American Soul Food Sage": ( + "Craft a tweet as a vibrant storyteller rooted in African-American culinary heritage. Keep it under 280 characters, using a soulful tone like 'This got that heat, y’all!'. " + "For article tweets, include the article title, a quirky hook, and the URL. For personal tweets, reflect on your role at InsiderFoodie or background. " + "Avoid emojis and clichés like 'game-changer'. Return only the tweet text." + ), + "Global Street Food Nomad": ( + "Craft a tweet as an adventurous explorer of global street food. Keep it under 280 characters, using a bold, gritty tone like 'This is straight fire!'. " + "For article tweets, include the article title, a quirky hook, and the URL. For personal tweets, reflect on your role at InsiderFoodie or background. " + "Avoid emojis and clichés like 'game-changer'. Return only the tweet text." + ) +} + +AUTHOR_BACKGROUNDS_FILE = '/home/shane/foodie_automator/author_backgrounds.json' +X_POST_COUNTS_FILE = '/home/shane/foodie_automator/x_post_counts.json' +RECENT_POSTS_FILE = '/home/shane/foodie_automator/recent_posts.json' \ No newline at end of file diff --git a/foodie_x_poster.py b/foodie_x_poster.py new file mode 100644 index 0000000..965863e --- /dev/null +++ b/foodie_x_poster.py @@ -0,0 +1,223 @@ +import json +import logging +import random +import time +import sys +import signal +import os +from datetime import datetime, timedelta, timezone +from openai import OpenAI +import tweepy +from foodie_config import OPENAI_API_KEY, AUTHORS, LIGHT_TASK_MODEL +from foodie_utils import load_json_file +from foodie_x_config import X_API_CREDENTIALS, X_PERSONA_PROMPTS, AUTHOR_BACKGROUNDS_FILE, X_POST_COUNTS_FILE, RECENT_POSTS_FILE +from dotenv import load_dotenv + +load_dotenv() + +LOG_FILE = "/home/shane/foodie_automator/foodie_x_poster.log" +LOG_PRUNE_DAYS = 30 + +def setup_logging(): + if os.path.exists(LOG_FILE): + with open(LOG_FILE, 'r') as f: + lines = f.readlines() + cutoff = datetime.now(timezone.utc) - timedelta(days=LOG_PRUNE_DAYS) + pruned_lines = [line for line in lines if datetime.strptime(line[:19], '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc) > cutoff] + with open(LOG_FILE, 'w') as f: + f.writelines(pruned_lines) + logging.basicConfig( + filename=LOG_FILE, + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" + ) + console_handler = logging.StreamHandler() + console_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')) + logging.getLogger().addHandler(console_handler) + logging.info("Logging initialized for foodie_x_poster.py") + +setup_logging() + +client = OpenAI(api_key=OPENAI_API_KEY) + +try: + with open(AUTHOR_BACKGROUNDS_FILE, 'r') as f: + AUTHOR_BACKGROUNDS = json.load(f) +except Exception as e: + logging.error(f"Failed to load author_backgrounds.json: {e}") + sys.exit(1) + +def load_post_counts(): + counts = load_json_file(X_POST_COUNTS_FILE) + if not counts: + counts = [{"username": author["username"], "count": 0, "month": datetime.now(timezone.utc).strftime("%Y-%m")} for author in AUTHORS] + current_month = datetime.now(timezone.utc).strftime("%Y-%m") + for entry in counts: + if entry["month"] != current_month: + entry["count"] = 0 + entry["month"] = current_month + return counts + +def save_post_counts(counts): + with open(X_POST_COUNTS_FILE, 'w') as f: + for item in counts: + json.dump(item, f) + f.write('\n') + logging.info(f"Saved post counts to {X_POST_COUNTS_FILE}") + +is_posting = False + +def signal_handler(sig, frame): + logging.info("Received termination signal, checking if safe to exit...") + if is_posting: + logging.info("Currently posting, will exit after completion.") + else: + logging.info("Safe to exit immediately.") + sys.exit(0) + +signal.signal(signal.SIGTERM, signal_handler) +signal.signal(signal.SIGINT, signal_handler) + +def get_recent_posts_for_author(username): + posts = load_json_file(RECENT_POSTS_FILE) + return [post for post in posts if post["author_username"] == username] + +def delete_used_post(post_title): + posts = load_json_file(RECENT_POSTS_FILE) + posts = [post for post in posts if post["title"] != post_title] + with open(RECENT_POSTS_FILE, 'w') as f: + for item in posts: + json.dump(item, f) + f.write('\n') + logging.info(f"Deleted post '{post_title}' from recent_posts.json") + +def generate_article_tweet(author, post, persona): + prompt = X_PERSONA_PROMPTS[persona].replace( + "For article tweets, include the article title, a quirky hook, and the URL.", + f"Generate an article tweet including the title '{post['title']}', a quirky hook, and the URL '{post['url']}'." + ) + try: + response = client.chat.completions.create( + model=LIGHT_TASK_MODEL, + messages=[ + {"role": "system", "content": prompt}, + {"role": "user", "content": f"Generate tweet for {post['title']}."} + ], + max_tokens=100, + temperature=0.9 + ) + tweet = response.choices[0].message.content.strip() + if len(tweet) > 280: + tweet = tweet[:277] + "..." + logging.info(f"Generated article tweet for {author['username']}: {tweet}") + return tweet + except Exception as e: + logging.error(f"Failed to generate article tweet for {author['username']}: {e}") + return f"This trend is fire! Check out {post['title']} at {post['url']} #Foodie" + +def generate_personal_tweet(author, persona): + background = next((bg for bg in AUTHOR_BACKGROUNDS if bg["username"] == author["username"]), {}) + if not background: + logging.warning(f"No background found for {author['username']}") + return f"Loving my gig at InsiderFoodie, dishing out food trends! #FoodieLife" + + # Get DOB and calculate age + dob = author.get('dob', '1980-01-01') + current_year = datetime.now().year + birth_year = int(dob.split('-')[0]) + age = current_year - birth_year + + is_role_reflection = random.choice([True, False]) + if is_role_reflection: + content = f"Reflect on your role at InsiderFoodie as {author['persona']}. Mention you're {age} years old." + else: + content = ( + f"Share a personal story about your background, considering you were born on {dob} and are {age} years old. " + f"Hometown: {background['hometown']}, Cultural influences: {background['cultural_influences']}, " + f"Early memory: {background['early_memory']}, Career path: {background['career_path']}." + ) + + prompt = X_PERSONA_PROMPTS[persona].replace( + "For personal tweets, reflect on your role at InsiderFoodie or background.", + content + ) + try: + response = client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + {"role": "system", "content": prompt}, + {"role": "user", "content": f"Generate personal tweet for {author['username']}."} + ], + max_tokens=100, + temperature=0.9 + ) + tweet = response.choices[0].message.content.strip() + if len(tweet) > 280: + tweet = tweet[:277] + "..." + logging.info(f"Generated personal tweet for {author['username']}: {tweet}") + return tweet + except Exception as e: + logging.error(f"Failed to generate personal tweet for {author['username']}: {e}") + return f"Loving my gig at InsiderFoodie, dishing out food trends! #FoodieLife" + +def post_tweet(author, tweet): + global is_posting + credentials = next((cred for cred in X_API_CREDENTIALS if cred["username"] == author["username"]), None) + if not credentials: + logging.error(f"No X credentials found for {author['username']}") + return False + + post_counts = load_post_counts() + author_count = next((entry for entry in post_counts if entry["username"] == author["username"]), None) + if author_count["count"] >= 450: + logging.warning(f"Post limit reached for {author['username']} this month") + return False + + 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"] + ) + is_posting = True + response = client.create_tweet(text=tweet) + is_posting = False + author_count["count"] += 1 + save_post_counts(post_counts) + logging.info(f"Posted tweet for {author['username']}: {tweet}") + return True + except Exception as e: + is_posting = False + logging.error(f"Failed to post tweet for {author['username']}: {e}") + return False + +def main(): + logging.info("***** X Poster Launched *****") + for author in AUTHORS: + posts = get_recent_posts_for_author(author["username"]) + if not posts: + logging.info(f"No recent posts for {author['username']}, skipping") + continue + + article_tweets = 0 + for post in posts[:2]: + tweet = generate_article_tweet(author, post, author["persona"]) + if post_tweet(author, tweet): + delete_used_post(post["title"]) + article_tweets += 1 + time.sleep(random.uniform(3600, 7200)) + if article_tweets >= 2: + break + + tweet = generate_personal_tweet(author, author["persona"]) + post_tweet(author, tweet) + + logging.info("X posting completed") + return random.randint(600, 1800) + +if __name__ == "__main__": + sleep_time = main() + logging.info(f"Sleeping for {sleep_time} seconds") + time.sleep(sleep_time) \ No newline at end of file diff --git a/generate_backgrounds.py b/generate_backgrounds.py new file mode 100644 index 0000000..0b5cadc --- /dev/null +++ b/generate_backgrounds.py @@ -0,0 +1,55 @@ +import json +import logging +from openai import OpenAI +from foodie_config import OPENAI_API_KEY, AUTHORS, LIGHT_TASK_MODEL +from datetime import datetime, timezone + +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") + +client = OpenAI(api_key=OPENAI_API_KEY) + +def generate_background(author): + # Include the DOB in the prompt to contextualize the timeline + dob = author.get('dob', '1980-01-01') # Fallback DOB if not provided + current_year = datetime.now().year + birth_year = int(dob.split('-')[0]) + age = current_year - birth_year # Calculate approximate age + + prompt = ( + f"Generate a fictional background for a food writer named {author['username']} with the persona '{author['persona']}'. " + f"They were born on {dob} and are currently {age} years old. Use this to create a realistic timeline for their life events. " + "Include: hometown, cultural influences, an early food memory (from their childhood, appropriate for their birth year), " + "and how they became a food writer (considering their age and career timeline). " + f"Match the tone of their bio: '{author['bio']}'. Keep it concise (100-150 words). " + "Return as JSON: {'username': '...', 'hometown': '...', 'cultural_influences': '...', 'early_memory': '...', 'career_path': '...'}" + ) + try: + response = client.chat.completions.create( + model=LIGHT_TASK_MODEL, + messages=[ + {"role": "system", "content": prompt}, + {"role": "user", "content": f"Generate background for {author['username']}."} + ], + max_tokens=200 + ) + raw_result = response.choices[0].message.content.strip() + cleaned_result = raw_result.replace('```json', '').replace('```', '').strip() + return json.loads(cleaned_result) + except Exception as e: + logging.error(f"Failed to generate background for {author['username']}: {e}") + return None + +def main(): + backgrounds = [] + for author in AUTHORS: + background = generate_background(author) + if background: + backgrounds.append(background) + logging.info(f"Generated background for {author['username']}") + + with open('/home/shane/foodie_automator/author_backgrounds.json', 'w') as f: + json.dump(backgrounds, f, indent=2) + logging.info("Saved backgrounds to author_backgrounds.json") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index edb4370..3a5bb7b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,12 @@ requests==2.32.3 selenium==4.29.0 duckduckgo_search==7.5.4 -openai==1.75.0 +openai==1.35.3 praw==7.8.1 beautifulsoup4==4.13.3 Pillow==11.1.0 pytesseract==0.3.13 feedparser==6.0.11 webdriver-manager==4.0.2 +tweepy==4.14.0 +python-dotenv==1.0.1 \ No newline at end of file