parent
36829eaeb8
commit
94ef0294bf
6 changed files with 447 additions and 19 deletions
@ -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' |
||||
@ -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) |
||||
@ -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() |
||||
@ -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 |
||||
Loading…
Reference in new issue