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 |
requests==2.32.3 |
||||||
selenium==4.29.0 |
selenium==4.29.0 |
||||||
duckduckgo_search==7.5.4 |
duckduckgo_search==7.5.4 |
||||||
openai==1.75.0 |
openai==1.35.3 |
||||||
praw==7.8.1 |
praw==7.8.1 |
||||||
beautifulsoup4==4.13.3 |
beautifulsoup4==4.13.3 |
||||||
Pillow==11.1.0 |
Pillow==11.1.0 |
||||||
pytesseract==0.3.13 |
pytesseract==0.3.13 |
||||||
feedparser==6.0.11 |
feedparser==6.0.11 |
||||||
webdriver-manager==4.0.2 |
webdriver-manager==4.0.2 |
||||||
|
tweepy==4.14.0 |
||||||
|
python-dotenv==1.0.1 |
||||||
Loading…
Reference in new issue