add lock files and update weekly tweet to include last tweet to follow
This commit is contained in:
+120
-36
@@ -29,12 +29,14 @@ from foodie_utils import (
|
|||||||
generate_category_from_summary, post_to_wp, prepare_post_data,
|
generate_category_from_summary, post_to_wp, prepare_post_data,
|
||||||
smart_image_and_filter, insert_link_naturally, get_flickr_image
|
smart_image_and_filter, insert_link_naturally, get_flickr_image
|
||||||
)
|
)
|
||||||
from foodie_hooks import get_dynamic_hook, get_viral_share_prompt # Removed select_best_cta import
|
from foodie_hooks import get_dynamic_hook, get_viral_share_prompt
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
import fcntl
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
is_posting = False
|
is_posting = False
|
||||||
|
LOCK_FILE = "/home/shane/foodie_automator/locks/foodie_automator_google.lock"
|
||||||
|
|
||||||
def signal_handler(sig, frame):
|
def signal_handler(sig, frame):
|
||||||
logging.info("Received termination signal, checking if safe to exit...")
|
logging.info("Received termination signal, checking if safe to exit...")
|
||||||
@@ -47,15 +49,58 @@ def signal_handler(sig, frame):
|
|||||||
signal.signal(signal.SIGTERM, signal_handler)
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
|
||||||
logger = logging.getLogger()
|
LOG_FILE = "/home/shane/foodie_automator/logs/foodie_automator_google.log"
|
||||||
logger.setLevel(logging.INFO)
|
LOG_PRUNE_DAYS = 30
|
||||||
file_handler = logging.FileHandler('/home/shane/foodie_automator/foodie_automator_google.log', mode='a')
|
MAX_RETRIES = 3
|
||||||
file_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
|
RETRY_BACKOFF = 2
|
||||||
logger.addHandler(file_handler)
|
|
||||||
console_handler = logging.StreamHandler()
|
posted_titles_data = load_json_file(POSTED_TITLES_FILE, EXPIRATION_HOURS)
|
||||||
console_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
|
posted_titles = set(entry["title"] for entry in posted_titles_data)
|
||||||
logger.addHandler(console_handler)
|
used_images = set(entry["title"] for entry in load_json_file(USED_IMAGES_FILE, IMAGE_EXPIRATION_DAYS) if "title" in entry)
|
||||||
logging.info("Logging initialized for foodie_automator_google.py")
|
|
||||||
|
def setup_logging():
|
||||||
|
if os.path.exists(LOG_FILE):
|
||||||
|
with open(LOG_FILE, 'r') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
log_entries = []
|
||||||
|
current_entry = []
|
||||||
|
timestamp_pattern = re.compile(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}')
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if timestamp_pattern.match(line):
|
||||||
|
if current_entry:
|
||||||
|
log_entries.append(''.join(current_entry))
|
||||||
|
current_entry = [line]
|
||||||
|
else:
|
||||||
|
current_entry.append(line)
|
||||||
|
|
||||||
|
if current_entry:
|
||||||
|
log_entries.append(''.join(current_entry))
|
||||||
|
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(days=LOG_PRUNE_DAYS)
|
||||||
|
pruned_entries = []
|
||||||
|
for entry in log_entries:
|
||||||
|
try:
|
||||||
|
timestamp = datetime.strptime(entry[:19], '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc)
|
||||||
|
if timestamp > cutoff:
|
||||||
|
pruned_entries.append(entry)
|
||||||
|
except ValueError:
|
||||||
|
logging.warning(f"Skipping malformed log entry (no timestamp): {entry[:50]}...")
|
||||||
|
continue
|
||||||
|
|
||||||
|
with open(LOG_FILE, 'w') as f:
|
||||||
|
f.writelines(pruned_entries)
|
||||||
|
|
||||||
|
logger = logging.getLogger()
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
file_handler = logging.FileHandler(LOG_FILE, mode='a')
|
||||||
|
file_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
|
||||||
|
logger.addHandler(file_handler)
|
||||||
|
console_handler = logging.StreamHandler()
|
||||||
|
console_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
|
||||||
|
logger.addHandler(console_handler)
|
||||||
|
logging.info("Logging initialized for foodie_automator_google.py")
|
||||||
|
|
||||||
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
||||||
|
|
||||||
@@ -68,6 +113,18 @@ 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)
|
||||||
|
|
||||||
|
def acquire_lock():
|
||||||
|
os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
|
||||||
|
lock_fd = open(LOCK_FILE, 'w')
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
lock_fd.write(str(os.getpid()))
|
||||||
|
lock_fd.flush()
|
||||||
|
return lock_fd
|
||||||
|
except IOError:
|
||||||
|
logging.info("Another instance of foodie_automator_google.py is running")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
def parse_search_volume(volume_text):
|
def parse_search_volume(volume_text):
|
||||||
try:
|
try:
|
||||||
volume_part = volume_text.split('\n')[0].lower().strip().replace('+', '')
|
volume_part = volume_text.split('\n')[0].lower().strip().replace('+', '')
|
||||||
@@ -89,10 +146,11 @@ def scrape_google_trends(geo='US'):
|
|||||||
chrome_options.add_argument("--disable-dev-shm-usage")
|
chrome_options.add_argument("--disable-dev-shm-usage")
|
||||||
chrome_options.add_argument("user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/125.0.0.0 Safari/537.36")
|
chrome_options.add_argument("user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/125.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
|
driver = None
|
||||||
|
try:
|
||||||
|
for attempt in range(MAX_RETRIES):
|
||||||
|
try:
|
||||||
driver = webdriver.Chrome(options=chrome_options)
|
driver = webdriver.Chrome(options=chrome_options)
|
||||||
try:
|
|
||||||
for attempt in range(3):
|
|
||||||
try:
|
|
||||||
time.sleep(random.uniform(2, 5))
|
time.sleep(random.uniform(2, 5))
|
||||||
url = f"https://trends.google.com/trending?geo={geo}&hours=24&sort=search-volume&category=5"
|
url = f"https://trends.google.com/trending?geo={geo}&hours=24&sort=search-volume&category=5"
|
||||||
logging.info(f"Navigating to {url} (attempt {attempt + 1})")
|
logging.info(f"Navigating to {url} (attempt {attempt + 1})")
|
||||||
@@ -105,10 +163,13 @@ def scrape_google_trends(geo='US'):
|
|||||||
break
|
break
|
||||||
except TimeoutException:
|
except TimeoutException:
|
||||||
logging.warning(f"Timeout on attempt {attempt + 1} for geo={geo}")
|
logging.warning(f"Timeout on attempt {attempt + 1} for geo={geo}")
|
||||||
if attempt == 2:
|
if attempt == MAX_RETRIES - 1:
|
||||||
logging.error(f"Failed after 3 attempts for geo={geo}")
|
logging.error(f"Failed after {MAX_RETRIES} attempts for geo={geo}")
|
||||||
return []
|
return []
|
||||||
time.sleep(5)
|
time.sleep(RETRY_BACKOFF * (2 ** attempt))
|
||||||
|
if driver:
|
||||||
|
driver.quit()
|
||||||
|
continue
|
||||||
|
|
||||||
driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
|
driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
@@ -145,15 +206,19 @@ def scrape_google_trends(geo='US'):
|
|||||||
if trends:
|
if trends:
|
||||||
trends.sort(key=lambda x: x["search_volume"], reverse=True)
|
trends.sort(key=lambda x: x["search_volume"], reverse=True)
|
||||||
logging.info(f"Extracted {len(trends)} trends for geo={geo}: {[t['title'] for t in trends]}")
|
logging.info(f"Extracted {len(trends)} trends for geo={geo}: {[t['title'] for t in trends]}")
|
||||||
print(f"Raw trends fetched for geo={geo}: {[t['title'] for t in trends]}")
|
|
||||||
else:
|
else:
|
||||||
logging.warning(f"No valid trends found with search volume >= 20K for geo={geo}")
|
logging.warning(f"No valid trends found with search volume >= 20K for geo={geo}")
|
||||||
return trends
|
return trends
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Unexpected error in scrape_google_trends: {e}", exc_info=True)
|
||||||
|
return []
|
||||||
finally:
|
finally:
|
||||||
|
if driver:
|
||||||
driver.quit()
|
driver.quit()
|
||||||
logging.info(f"Chrome driver closed for geo={geo}")
|
logging.info(f"Chrome driver closed for geo={geo}")
|
||||||
|
|
||||||
def fetch_duckduckgo_news_context(trend_title, hours=24):
|
def fetch_duckduckgo_news_context(trend_title, hours=24):
|
||||||
|
for attempt in range(MAX_RETRIES):
|
||||||
try:
|
try:
|
||||||
with DDGS() as ddgs:
|
with DDGS() as ddgs:
|
||||||
results = ddgs.news(f"{trend_title} news", timelimit="d", max_results=5)
|
results = ddgs.news(f"{trend_title} news", timelimit="d", max_results=5)
|
||||||
@@ -174,10 +239,15 @@ def fetch_duckduckgo_news_context(trend_title, hours=24):
|
|||||||
logging.info(f"DuckDuckGo News context for '{trend_title}': {context}")
|
logging.info(f"DuckDuckGo News context for '{trend_title}': {context}")
|
||||||
return context
|
return context
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"DuckDuckGo News context fetch failed for '{trend_title}': {e}")
|
logging.warning(f"DuckDuckGo News context fetch failed for '{trend_title}' (attempt {attempt + 1}): {e}")
|
||||||
|
if attempt < MAX_RETRIES - 1:
|
||||||
|
time.sleep(RETRY_BACKOFF * (2 ** attempt))
|
||||||
|
continue
|
||||||
|
logging.error(f"Failed to fetch DuckDuckGo News context for '{trend_title}' after {MAX_RETRIES} attempts")
|
||||||
return trend_title
|
return trend_title
|
||||||
|
|
||||||
def curate_from_google_trends(geo_list=['US']):
|
def curate_from_google_trends(geo_list=['US']):
|
||||||
|
try:
|
||||||
all_trends = []
|
all_trends = []
|
||||||
for geo in geo_list:
|
for geo in geo_list:
|
||||||
trends = scrape_google_trends(geo=geo)
|
trends = scrape_google_trends(geo=geo)
|
||||||
@@ -185,9 +255,8 @@ def curate_from_google_trends(geo_list=['US']):
|
|||||||
all_trends.extend(trends)
|
all_trends.extend(trends)
|
||||||
|
|
||||||
if not all_trends:
|
if not all_trends:
|
||||||
print("No Google Trends data available")
|
|
||||||
logging.info("No Google Trends data available")
|
logging.info("No Google Trends data available")
|
||||||
return None, None, random.randint(600, 1800)
|
return None, None, False
|
||||||
|
|
||||||
attempts = 0
|
attempts = 0
|
||||||
max_attempts = 10
|
max_attempts = 10
|
||||||
@@ -200,17 +269,14 @@ def curate_from_google_trends(geo_list=['US']):
|
|||||||
original_source = f'<a href="{link}">{source_name}</a>'
|
original_source = f'<a href="{link}">{source_name}</a>'
|
||||||
|
|
||||||
if title in posted_titles:
|
if title in posted_titles:
|
||||||
print(f"Skipping already posted trend: {title}")
|
|
||||||
logging.info(f"Skipping already posted trend: {title}")
|
logging.info(f"Skipping already posted trend: {title}")
|
||||||
attempts += 1
|
attempts += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
print(f"Trying Google Trend: {title} from {source_name}")
|
|
||||||
logging.info(f"Trying Google Trend: {title} from {source_name}")
|
logging.info(f"Trying Google Trend: {title} from {source_name}")
|
||||||
|
|
||||||
image_query, relevance_keywords, main_topic, skip = smart_image_and_filter(title, summary)
|
image_query, relevance_keywords, main_topic, skip = smart_image_and_filter(title, summary)
|
||||||
if skip:
|
if skip:
|
||||||
print(f"Skipping filtered Google Trend: {title}")
|
|
||||||
logging.info(f"Skipping filtered Google Trend: {title}")
|
logging.info(f"Skipping filtered Google Trend: {title}")
|
||||||
attempts += 1
|
attempts += 1
|
||||||
continue
|
continue
|
||||||
@@ -220,7 +286,6 @@ def curate_from_google_trends(geo_list=['US']):
|
|||||||
interest_score = is_interesting(scoring_content)
|
interest_score = is_interesting(scoring_content)
|
||||||
logging.info(f"Interest score for '{title}': {interest_score}")
|
logging.info(f"Interest score for '{title}': {interest_score}")
|
||||||
if interest_score < 6:
|
if interest_score < 6:
|
||||||
print(f"Google Trends Interest Too Low: {interest_score}")
|
|
||||||
logging.info(f"Google Trends Interest Too Low: {interest_score}")
|
logging.info(f"Google Trends Interest Too Low: {interest_score}")
|
||||||
attempts += 1
|
attempts += 1
|
||||||
continue
|
continue
|
||||||
@@ -284,6 +349,10 @@ def curate_from_google_trends(geo_list=['US']):
|
|||||||
interest_score=interest_score,
|
interest_score=interest_score,
|
||||||
should_post_tweet=True
|
should_post_tweet=True
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Failed to post to WordPress for '{title}': {e}", exc_info=True)
|
||||||
|
attempts += 1
|
||||||
|
continue
|
||||||
finally:
|
finally:
|
||||||
is_posting = False
|
is_posting = False
|
||||||
|
|
||||||
@@ -309,6 +378,8 @@ def curate_from_google_trends(geo_list=['US']):
|
|||||||
post_id=post_id,
|
post_id=post_id,
|
||||||
should_post_tweet=False
|
should_post_tweet=False
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Failed to update WordPress post '{title}' with share links: {e}", exc_info=True)
|
||||||
finally:
|
finally:
|
||||||
is_posting = False
|
is_posting = False
|
||||||
|
|
||||||
@@ -322,27 +393,40 @@ def curate_from_google_trends(geo_list=['US']):
|
|||||||
used_images.add(image_url)
|
used_images.add(image_url)
|
||||||
logging.info(f"Saved image '{image_url}' to {USED_IMAGES_FILE}")
|
logging.info(f"Saved image '{image_url}' to {USED_IMAGES_FILE}")
|
||||||
|
|
||||||
print(f"***** SUCCESS: Posted '{post_data['title']}' (ID: {post_id}) from Google Trends *****")
|
|
||||||
logging.info(f"***** SUCCESS: Posted '{post_data['title']}' (ID: {post_id}) from Google Trends *****")
|
logging.info(f"***** SUCCESS: Posted '{post_data['title']}' (ID: {post_id}) from Google Trends *****")
|
||||||
return post_data, category, random.randint(0, 1800)
|
return post_data, category, True
|
||||||
|
|
||||||
attempts += 1
|
attempts += 1
|
||||||
logging.info(f"WP posting failed for '{post_data['title']}'")
|
logging.info(f"WP posting failed for '{post_data['title']}'")
|
||||||
|
|
||||||
print("No interesting Google Trend found after attempts")
|
|
||||||
logging.info("No interesting Google Trend found after attempts")
|
logging.info("No interesting Google Trend found after attempts")
|
||||||
return None, None, random.randint(600, 1800)
|
return None, None, False
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Unexpected error in curate_from_google_trends: {e}", exc_info=True)
|
||||||
|
return None, None, False
|
||||||
|
|
||||||
def run_google_trends_automator():
|
def run_google_trends_automator():
|
||||||
|
lock_fd = None
|
||||||
|
try:
|
||||||
|
lock_fd = acquire_lock()
|
||||||
logging.info("***** Google Trends Automator Launched *****")
|
logging.info("***** Google Trends Automator Launched *****")
|
||||||
geo_list = ['US', 'GB', 'AU']
|
geo_list = ['US', 'GB', 'AU']
|
||||||
post_data, category, sleep_time = curate_from_google_trends(geo_list=geo_list)
|
post_data, category, should_continue = curate_from_google_trends(geo_list=geo_list)
|
||||||
if sleep_time is None:
|
if not post_data:
|
||||||
sleep_time = random.randint(600, 1800)
|
logging.info("No postable Google Trend found")
|
||||||
print(f"Sleeping for {sleep_time}s")
|
else:
|
||||||
logging.info(f"Completed run with sleep time: {sleep_time} seconds")
|
logging.info("Completed Google Trends run")
|
||||||
time.sleep(sleep_time)
|
return post_data, category, should_continue
|
||||||
return post_data, category, sleep_time
|
except Exception as e:
|
||||||
|
logging.error(f"Fatal error in run_google_trends_automator: {e}", exc_info=True)
|
||||||
|
return None, None, False
|
||||||
|
finally:
|
||||||
|
if lock_fd:
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||||
|
lock_fd.close()
|
||||||
|
os.remove(LOCK_FILE) if os.path.exists(LOCK_FILE) else None
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
run_google_trends_automator()
|
setup_logging()
|
||||||
|
post_data, category, should_continue = run_google_trends_automator()
|
||||||
|
logging.info(f"Run completed, should_continue: {should_continue}")
|
||||||
+92
-50
@@ -29,11 +29,13 @@ from foodie_utils import (
|
|||||||
prepare_post_data, select_best_author, smart_image_and_filter,
|
prepare_post_data, select_best_author, smart_image_and_filter,
|
||||||
get_flickr_image
|
get_flickr_image
|
||||||
)
|
)
|
||||||
from foodie_hooks import get_dynamic_hook, get_viral_share_prompt # Removed select_best_cta import
|
from foodie_hooks import get_dynamic_hook, get_viral_share_prompt
|
||||||
|
import fcntl
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
is_posting = False
|
is_posting = False
|
||||||
|
LOCK_FILE = "/home/shane/foodie_automator/locks/foodie_automator_reddit.lock"
|
||||||
|
|
||||||
def signal_handler(sig, frame):
|
def signal_handler(sig, frame):
|
||||||
logging.info("Received termination signal, checking if safe to exit...")
|
logging.info("Received termination signal, checking if safe to exit...")
|
||||||
@@ -46,8 +48,22 @@ def signal_handler(sig, frame):
|
|||||||
signal.signal(signal.SIGTERM, signal_handler)
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
|
||||||
LOG_FILE = "/home/shane/foodie_automator/foodie_automator_reddit.log"
|
LOG_FILE = "/home/shane/foodie_automator/logs/foodie_automator_reddit.log"
|
||||||
LOG_PRUNE_DAYS = 30
|
LOG_PRUNE_DAYS = 30
|
||||||
|
MAX_RETRIES = 3
|
||||||
|
RETRY_BACKOFF = 2
|
||||||
|
|
||||||
|
POSTED_TITLES_FILE = '/home/shane/foodie_automator/posted_reddit_titles.json'
|
||||||
|
USED_IMAGES_FILE = '/home/shane/foodie_automator/used_images.json'
|
||||||
|
EXPIRATION_HOURS = 24
|
||||||
|
IMAGE_EXPIRATION_DAYS = 7
|
||||||
|
|
||||||
|
posted_titles_data = load_json_file(POSTED_TITLES_FILE, EXPIRATION_HOURS)
|
||||||
|
posted_titles = set(entry["title"] for entry in posted_titles_data if "title" in entry)
|
||||||
|
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)
|
||||||
|
|
||||||
|
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
||||||
|
|
||||||
def setup_logging():
|
def setup_logging():
|
||||||
if os.path.exists(LOG_FILE):
|
if os.path.exists(LOG_FILE):
|
||||||
@@ -59,7 +75,7 @@ def setup_logging():
|
|||||||
timestamp_pattern = re.compile(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}')
|
timestamp_pattern = re.compile(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}')
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
if timestamp_pattern.match(line):
|
if(timestamp_pattern.match(line)):
|
||||||
if current_entry:
|
if current_entry:
|
||||||
log_entries.append(''.join(current_entry))
|
log_entries.append(''.join(current_entry))
|
||||||
current_entry = [line]
|
current_entry = [line]
|
||||||
@@ -95,19 +111,17 @@ def setup_logging():
|
|||||||
logging.getLogger().addHandler(console_handler)
|
logging.getLogger().addHandler(console_handler)
|
||||||
logging.info("Logging initialized for foodie_automator_reddit.py")
|
logging.info("Logging initialized for foodie_automator_reddit.py")
|
||||||
|
|
||||||
setup_logging()
|
def acquire_lock():
|
||||||
|
os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
|
||||||
POSTED_TITLES_FILE = '/home/shane/foodie_automator/posted_reddit_titles.json'
|
lock_fd = open(LOCK_FILE, 'w')
|
||||||
USED_IMAGES_FILE = '/home/shane/foodie_automator/used_images.json'
|
try:
|
||||||
EXPIRATION_HOURS = 24
|
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
IMAGE_EXPIRATION_DAYS = 7
|
lock_fd.write(str(os.getpid()))
|
||||||
|
lock_fd.flush()
|
||||||
posted_titles_data = load_json_file(POSTED_TITLES_FILE, EXPIRATION_HOURS)
|
return lock_fd
|
||||||
posted_titles = set(entry["title"] for entry in posted_titles_data if "title" in entry)
|
except IOError:
|
||||||
used_images_data = load_json_file(USED_IMAGES_FILE, IMAGE_EXPIRATION_DAYS)
|
logging.info("Another instance of foodie_automator_reddit.py is running")
|
||||||
used_images = set(entry["title"] for entry in used_images_data if "title" in entry)
|
sys.exit(0)
|
||||||
|
|
||||||
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
|
||||||
|
|
||||||
def clean_reddit_title(title):
|
def clean_reddit_title(title):
|
||||||
cleaned_title = re.sub(r'^\[.*?\]\s*', '', title).strip()
|
cleaned_title = re.sub(r'^\[.*?\]\s*', '', title).strip()
|
||||||
@@ -115,6 +129,7 @@ def clean_reddit_title(title):
|
|||||||
return cleaned_title
|
return cleaned_title
|
||||||
|
|
||||||
def is_interesting_reddit(title, summary, upvotes, comment_count, top_comments):
|
def is_interesting_reddit(title, summary, upvotes, comment_count, top_comments):
|
||||||
|
for attempt in range(MAX_RETRIES):
|
||||||
try:
|
try:
|
||||||
content = f"Title: {title}\n\nContent: {summary}"
|
content = f"Title: {title}\n\nContent: {summary}"
|
||||||
if top_comments:
|
if top_comments:
|
||||||
@@ -128,7 +143,7 @@ def is_interesting_reddit(title, summary, upvotes, comment_count, top_comments):
|
|||||||
"Score 8-10 for rare, highly shareable ideas (e.g., unique dishes or restaurant trends). "
|
"Score 8-10 for rare, highly shareable ideas (e.g., unique dishes or restaurant trends). "
|
||||||
"Score 5-7 for fresh, engaging updates with broad appeal. Score below 5 for common or unremarkable content. "
|
"Score 5-7 for fresh, engaging updates with broad appeal. Score below 5 for common or unremarkable content. "
|
||||||
"Consider comments for added context (e.g., specific locations or unique details). "
|
"Consider comments for added context (e.g., specific locations or unique details). "
|
||||||
"Return only a number."
|
"Return only a number"
|
||||||
)},
|
)},
|
||||||
{"role": "user", "content": content}
|
{"role": "user", "content": content}
|
||||||
],
|
],
|
||||||
@@ -151,14 +166,17 @@ def is_interesting_reddit(title, summary, upvotes, comment_count, top_comments):
|
|||||||
|
|
||||||
final_score = min(base_score + engagement_boost, 10)
|
final_score = min(base_score + engagement_boost, 10)
|
||||||
logging.info(f"Reddit Interest Score: {final_score} (base: {base_score}, upvotes: {upvotes}, comments: {comment_count}, top_comments: {len(top_comments)}) for '{title}'")
|
logging.info(f"Reddit Interest Score: {final_score} (base: {base_score}, upvotes: {upvotes}, comments: {comment_count}, top_comments: {len(top_comments)}) for '{title}'")
|
||||||
print(f"Interest Score for '{title[:50]}...': {final_score} (base: {base_score}, upvotes: {upvotes}, comments: {comment_count})")
|
|
||||||
return final_score
|
return final_score
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Reddit interestingness scoring failed: {e}")
|
logging.warning(f"Reddit interestingness scoring failed (attempt {attempt + 1}): {e}")
|
||||||
print(f"Reddit Interest Error: {e}")
|
if attempt < MAX_RETRIES - 1:
|
||||||
|
time.sleep(RETRY_BACKOFF * (2 ** attempt))
|
||||||
|
continue
|
||||||
|
logging.error(f"Failed to score Reddit post '{title}' after {MAX_RETRIES} attempts")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def get_top_comments(post_url, reddit, limit=3):
|
def get_top_comments(post_url, reddit, limit=3):
|
||||||
|
for attempt in range(MAX_RETRIES):
|
||||||
try:
|
try:
|
||||||
submission = reddit.submission(url=post_url)
|
submission = reddit.submission(url=post_url)
|
||||||
submission.comment_sort = 'top'
|
submission.comment_sort = 'top'
|
||||||
@@ -167,10 +185,15 @@ def get_top_comments(post_url, reddit, limit=3):
|
|||||||
logging.info(f"Fetched {len(top_comments)} top comments for {post_url}")
|
logging.info(f"Fetched {len(top_comments)} top comments for {post_url}")
|
||||||
return top_comments
|
return top_comments
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to fetch comments for {post_url}: {e}")
|
logging.warning(f"Failed to fetch comments for {post_url} (attempt {attempt + 1}): {e}")
|
||||||
|
if attempt < MAX_RETRIES - 1:
|
||||||
|
time.sleep(RETRY_BACKOFF * (2 ** attempt))
|
||||||
|
continue
|
||||||
|
logging.error(f"Failed to fetch comments for {post_url} after {MAX_RETRIES} attempts")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def fetch_duckduckgo_news_context(title, hours=24):
|
def fetch_duckduckgo_news_context(title, hours=24):
|
||||||
|
for attempt in range(MAX_RETRIES):
|
||||||
try:
|
try:
|
||||||
with DDGS() as ddgs:
|
with DDGS() as ddgs:
|
||||||
results = ddgs.news(f"{title} news", timelimit="d", max_results=5)
|
results = ddgs.news(f"{title} news", timelimit="d", max_results=5)
|
||||||
@@ -191,10 +214,15 @@ def fetch_duckduckgo_news_context(title, hours=24):
|
|||||||
logging.info(f"DuckDuckGo News context for '{title}': {context}")
|
logging.info(f"DuckDuckGo News context for '{title}': {context}")
|
||||||
return context
|
return context
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"DuckDuckGo News context fetch failed for '{title}': {e}")
|
logging.warning(f"DuckDuckGo News context fetch failed for '{title}' (attempt {attempt + 1}): {e}")
|
||||||
|
if attempt < MAX_RETRIES - 1:
|
||||||
|
time.sleep(RETRY_BACKOFF * (2 ** attempt))
|
||||||
|
continue
|
||||||
|
logging.error(f"Failed to fetch DuckDuckGo News context for '{title}' after {MAX_RETRIES} attempts")
|
||||||
return title
|
return title
|
||||||
|
|
||||||
def fetch_reddit_posts():
|
def fetch_reddit_posts():
|
||||||
|
try:
|
||||||
reddit = praw.Reddit(
|
reddit = praw.Reddit(
|
||||||
client_id=REDDIT_CLIENT_ID,
|
client_id=REDDIT_CLIENT_ID,
|
||||||
client_secret=REDDIT_CLIENT_SECRET,
|
client_secret=REDDIT_CLIENT_SECRET,
|
||||||
@@ -206,6 +234,7 @@ def fetch_reddit_posts():
|
|||||||
|
|
||||||
logging.info(f"Starting fetch with cutoff date: {cutoff_date}")
|
logging.info(f"Starting fetch with cutoff date: {cutoff_date}")
|
||||||
for subreddit_name in feeds:
|
for subreddit_name in feeds:
|
||||||
|
for attempt in range(MAX_RETRIES):
|
||||||
try:
|
try:
|
||||||
subreddit = reddit.subreddit(subreddit_name)
|
subreddit = reddit.subreddit(subreddit_name)
|
||||||
for submission in subreddit.top(time_filter='day', limit=100):
|
for submission in subreddit.top(time_filter='day', limit=100):
|
||||||
@@ -225,18 +254,24 @@ def fetch_reddit_posts():
|
|||||||
"comment_count": submission.num_comments
|
"comment_count": submission.num_comments
|
||||||
})
|
})
|
||||||
logging.info(f"Fetched {len(articles)} posts from r/{subreddit_name}")
|
logging.info(f"Fetched {len(articles)} posts from r/{subreddit_name}")
|
||||||
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to fetch Reddit feed r/{subreddit_name}: {e}")
|
logging.error(f"Failed to fetch Reddit feed r/{subreddit_name} (attempt {attempt + 1}): {e}")
|
||||||
|
if attempt < MAX_RETRIES - 1:
|
||||||
|
time.sleep(RETRY_BACKOFF * (2 ** attempt))
|
||||||
|
continue
|
||||||
logging.info(f"Total Reddit posts fetched: {len(articles)}")
|
logging.info(f"Total Reddit posts fetched: {len(articles)}")
|
||||||
return articles
|
return articles
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Unexpected error in fetch_reddit_posts: {e}", exc_info=True)
|
||||||
|
return []
|
||||||
|
|
||||||
def curate_from_reddit():
|
def curate_from_reddit():
|
||||||
|
try:
|
||||||
articles = fetch_reddit_posts()
|
articles = fetch_reddit_posts()
|
||||||
if not articles:
|
if not articles:
|
||||||
print("No Reddit posts available")
|
|
||||||
logging.info("No Reddit posts available")
|
logging.info("No Reddit posts available")
|
||||||
return None, None, random.randint(600, 1800)
|
return None, None, False
|
||||||
|
|
||||||
articles.sort(key=lambda x: x["upvotes"], reverse=True)
|
articles.sort(key=lambda x: x["upvotes"], reverse=True)
|
||||||
|
|
||||||
@@ -258,17 +293,14 @@ def curate_from_reddit():
|
|||||||
original_source = '<a href="https://www.reddit.com/">Reddit</a>'
|
original_source = '<a href="https://www.reddit.com/">Reddit</a>'
|
||||||
|
|
||||||
if raw_title in posted_titles:
|
if raw_title in posted_titles:
|
||||||
print(f"Skipping already posted post: {raw_title}")
|
|
||||||
logging.info(f"Skipping already posted post: {raw_title}")
|
logging.info(f"Skipping already posted post: {raw_title}")
|
||||||
attempts += 1
|
attempts += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
print(f"Trying Reddit Post: {title} from {source_name}")
|
|
||||||
logging.info(f"Trying Reddit Post: {title} from {source_name}")
|
logging.info(f"Trying Reddit Post: {title} from {source_name}")
|
||||||
|
|
||||||
image_query, relevance_keywords, main_topic, skip = smart_image_and_filter(title, summary)
|
image_query, relevance_keywords, main_topic, skip = smart_image_and_filter(title, summary)
|
||||||
if skip or any(keyword in title.lower() or keyword in raw_title.lower() for keyword in RECIPE_KEYWORDS + ["homemade"]):
|
if skip or any(keyword in title.lower() or keyword in raw_title.lower() for keyword in RECIPE_KEYWORDS + ["homemade"]):
|
||||||
print(f"Skipping filtered Reddit post: {title}")
|
|
||||||
logging.info(f"Skipping filtered Reddit post: {title}")
|
logging.info(f"Skipping filtered Reddit post: {title}")
|
||||||
attempts += 1
|
attempts += 1
|
||||||
continue
|
continue
|
||||||
@@ -285,7 +317,6 @@ def curate_from_reddit():
|
|||||||
)
|
)
|
||||||
logging.info(f"Interest Score: {interest_score} for '{title}'")
|
logging.info(f"Interest Score: {interest_score} for '{title}'")
|
||||||
if interest_score < 6:
|
if interest_score < 6:
|
||||||
print(f"Reddit Interest Too Low: {interest_score}")
|
|
||||||
logging.info(f"Reddit Interest Too Low: {interest_score}")
|
logging.info(f"Reddit Interest Too Low: {interest_score}")
|
||||||
attempts += 1
|
attempts += 1
|
||||||
continue
|
continue
|
||||||
@@ -350,6 +381,10 @@ def curate_from_reddit():
|
|||||||
interest_score=interest_score,
|
interest_score=interest_score,
|
||||||
should_post_tweet=True
|
should_post_tweet=True
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Failed to post to WordPress for '{title}': {e}", exc_info=True)
|
||||||
|
attempts += 1
|
||||||
|
continue
|
||||||
finally:
|
finally:
|
||||||
is_posting = False
|
is_posting = False
|
||||||
|
|
||||||
@@ -375,46 +410,53 @@ def curate_from_reddit():
|
|||||||
post_id=post_id,
|
post_id=post_id,
|
||||||
should_post_tweet=False
|
should_post_tweet=False
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Failed to update WordPress post '{title}' with share links: {e}", exc_info=True)
|
||||||
finally:
|
finally:
|
||||||
is_posting = False
|
is_posting = False
|
||||||
|
|
||||||
timestamp = datetime.now(timezone.utc).isoformat()
|
timestamp = datetime.now(timezone.utc).isoformat()
|
||||||
save_json_file(POSTED_TITLES_FILE, raw_title, timestamp)
|
save_json_file(POSTED_TITLES_FILE, raw_title, timestamp)
|
||||||
posted_titles.add(raw_title)
|
posted_titles.add(raw_title)
|
||||||
logging.info(f"Successfully saved '{raw_title}' to {POSTED_TITLES_FILE} with timestamp {timestamp}")
|
logging.info(f"Successfully saved '{raw_title}' to {POSTED_TITLES_FILE}")
|
||||||
|
|
||||||
if image_url:
|
if image_url:
|
||||||
save_json_file(USED_IMAGES_FILE, image_url, timestamp)
|
save_json_file(USED_IMAGES_FILE, image_url, timestamp)
|
||||||
used_images.add(image_url)
|
used_images.add(image_url)
|
||||||
logging.info(f"Saved image '{image_url}' to {USED_IMAGES_FILE} with timestamp {timestamp}")
|
logging.info(f"Saved image '{image_url}' to {USED_IMAGES_FILE}")
|
||||||
|
|
||||||
print(f"***** SUCCESS: Posted '{post_data['title']}' (ID: {post_id}) from Reddit *****")
|
|
||||||
print(f"Actual post URL: {post_url}")
|
|
||||||
logging.info(f"***** SUCCESS: Posted '{post_data['title']}' (ID: {post_id}) from Reddit *****")
|
logging.info(f"***** SUCCESS: Posted '{post_data['title']}' (ID: {post_id}) from Reddit *****")
|
||||||
logging.info(f"Actual post URL: {post_url}")
|
return post_data, category, True
|
||||||
return post_data, category, random.randint(0, 1800)
|
|
||||||
|
|
||||||
attempts += 1
|
attempts += 1
|
||||||
logging.info(f"WP posting failed for '{post_data['title']}'")
|
logging.info(f"WP posting failed for '{post_data['title']}'")
|
||||||
|
|
||||||
print("No interesting Reddit post found after attempts")
|
|
||||||
logging.info("No interesting Reddit post found after attempts")
|
logging.info("No interesting Reddit post found after attempts")
|
||||||
return None, None, random.randint(600, 1800)
|
return None, None, False
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Unexpected error in curate_from_reddit: {e}", exc_info=True)
|
||||||
|
return None, None, False
|
||||||
|
|
||||||
def run_reddit_automator():
|
def run_reddit_automator():
|
||||||
print(f"{datetime.now(timezone.utc)} - INFO - ***** Reddit Automator Launched *****")
|
lock_fd = None
|
||||||
|
try:
|
||||||
|
lock_fd = acquire_lock()
|
||||||
logging.info("***** Reddit Automator Launched *****")
|
logging.info("***** Reddit Automator Launched *****")
|
||||||
|
post_data, category, should_continue = curate_from_reddit()
|
||||||
post_data, category, sleep_time = curate_from_reddit()
|
|
||||||
if not post_data:
|
if not post_data:
|
||||||
print(f"No postable Reddit article found - sleeping for {sleep_time} seconds")
|
logging.info("No postable Reddit article found")
|
||||||
logging.info(f"No postable Reddit article found - sleeping for {sleep_time} seconds")
|
|
||||||
else:
|
else:
|
||||||
print(f"Completed Reddit run with sleep time: {sleep_time} seconds")
|
logging.info("Completed Reddit run")
|
||||||
logging.info(f"Completed Reddit run with sleep time: {sleep_time} seconds")
|
return post_data, category, should_continue
|
||||||
print(f"Sleeping for {sleep_time}s")
|
except Exception as e:
|
||||||
time.sleep(sleep_time)
|
logging.error(f"Fatal error in run_reddit_automator: {e}", exc_info=True)
|
||||||
return post_data, category, sleep_time
|
return None, None, False
|
||||||
|
finally:
|
||||||
|
if lock_fd:
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||||
|
lock_fd.close()
|
||||||
|
os.remove(LOCK_FILE) if os.path.exists(LOCK_FILE) else None
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
run_reddit_automator()
|
setup_logging()
|
||||||
|
post_data, category, should_continue = run_reddit_automator()
|
||||||
|
logging.info(f"Run completed, should_continue: {should_continue}")
|
||||||
+65
-32
@@ -31,10 +31,12 @@ from foodie_utils import (
|
|||||||
)
|
)
|
||||||
from foodie_hooks import get_dynamic_hook, get_viral_share_prompt
|
from foodie_hooks import get_dynamic_hook, get_viral_share_prompt
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
import fcntl
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
is_posting = False
|
is_posting = False
|
||||||
|
LOCK_FILE = "/home/shane/foodie_automator/locks/foodie_automator_rss.lock"
|
||||||
|
|
||||||
def signal_handler(sig, frame):
|
def signal_handler(sig, frame):
|
||||||
logging.info("Received termination signal, checking if safe to exit...")
|
logging.info("Received termination signal, checking if safe to exit...")
|
||||||
@@ -47,10 +49,11 @@ def signal_handler(sig, frame):
|
|||||||
signal.signal(signal.SIGTERM, signal_handler)
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
|
||||||
LOG_FILE = "/home/shane/foodie_automator/foodie_automator_rss.log"
|
LOG_FILE = "/home/shane/foodie_automator/logs/foodie_automator_rss.log"
|
||||||
LOG_PRUNE_DAYS = 30
|
LOG_PRUNE_DAYS = 30
|
||||||
FEED_TIMEOUT = 15
|
FEED_TIMEOUT = 15
|
||||||
MAX_RETRIES = 3
|
MAX_RETRIES = 3
|
||||||
|
RETRY_BACKOFF = 2
|
||||||
|
|
||||||
POSTED_TITLES_FILE = '/home/shane/foodie_automator/posted_rss_titles.json'
|
POSTED_TITLES_FILE = '/home/shane/foodie_automator/posted_rss_titles.json'
|
||||||
USED_IMAGES_FILE = '/home/shane/foodie_automator/used_images.json'
|
USED_IMAGES_FILE = '/home/shane/foodie_automator/used_images.json'
|
||||||
@@ -96,21 +99,27 @@ def setup_logging():
|
|||||||
logging.getLogger("requests").setLevel(logging.WARNING)
|
logging.getLogger("requests").setLevel(logging.WARNING)
|
||||||
logging.info("Logging initialized for foodie_automator_rss.py")
|
logging.info("Logging initialized for foodie_automator_rss.py")
|
||||||
|
|
||||||
setup_logging()
|
def acquire_lock():
|
||||||
|
os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
|
||||||
|
lock_fd = open(LOCK_FILE, 'w')
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
lock_fd.write(str(os.getpid()))
|
||||||
|
lock_fd.flush()
|
||||||
|
return lock_fd
|
||||||
|
except IOError:
|
||||||
|
logging.info("Another instance of foodie_automator_rss.py is running")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
def create_http_session() -> requests.Session:
|
def create_http_session() -> requests.Session:
|
||||||
session = requests.Session()
|
session = requests.Session()
|
||||||
retry_strategy = Retry(
|
retry_strategy = Retry(
|
||||||
total=MAX_RETRIES,
|
total=MAX_RETRIES,
|
||||||
backoff_factor=2,
|
backoff_factor=RETRY_BACKOFF,
|
||||||
status_forcelist=[403, 429, 500, 502, 503, 504],
|
status_forcelist=[403, 429, 500, 502, 503, 504],
|
||||||
allowed_methods=["GET", "POST"]
|
allowed_methods=["GET", "POST"]
|
||||||
)
|
)
|
||||||
adapter = HTTPAdapter(
|
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||||
max_retries=retry_strategy,
|
|
||||||
pool_connections=10,
|
|
||||||
pool_maxsize=10
|
|
||||||
)
|
|
||||||
session.mount("http://", adapter)
|
session.mount("http://", adapter)
|
||||||
session.mount("https://", adapter)
|
session.mount("https://", adapter)
|
||||||
session.headers.update({
|
session.headers.update({
|
||||||
@@ -140,7 +149,8 @@ def fetch_rss_feeds():
|
|||||||
|
|
||||||
logging.info(f"Processing feeds: {RSS_FEEDS}")
|
logging.info(f"Processing feeds: {RSS_FEEDS}")
|
||||||
for feed_url in RSS_FEEDS:
|
for feed_url in RSS_FEEDS:
|
||||||
logging.info(f"Processing feed: {feed_url}")
|
for attempt in range(MAX_RETRIES):
|
||||||
|
logging.info(f"Processing feed: {feed_url} (attempt {attempt + 1})")
|
||||||
try:
|
try:
|
||||||
response = session.get(feed_url, timeout=FEED_TIMEOUT)
|
response = session.get(feed_url, timeout=FEED_TIMEOUT)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
@@ -177,15 +187,18 @@ def fetch_rss_feeds():
|
|||||||
logging.warning(f"Error processing entry in {feed_url}: {e}")
|
logging.warning(f"Error processing entry in {feed_url}: {e}")
|
||||||
continue
|
continue
|
||||||
logging.info(f"Filtered to {len(articles)} articles from {feed_url}")
|
logging.info(f"Filtered to {len(articles)} articles from {feed_url}")
|
||||||
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to fetch RSS feed {feed_url}: {e}")
|
logging.error(f"Failed to fetch RSS feed {feed_url}: {e}")
|
||||||
|
if attempt < MAX_RETRIES - 1:
|
||||||
|
time.sleep(RETRY_BACKOFF * (2 ** attempt))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
articles.sort(key=lambda x: x["pub_date"], reverse=True)
|
articles.sort(key=lambda x: x["pub_date"], reverse=True)
|
||||||
logging.info(f"Total RSS articles fetched: {len(articles)}")
|
logging.info(f"Total RSS articles fetched: {len(articles)}")
|
||||||
return articles
|
return articles
|
||||||
|
|
||||||
def fetch_duckduckgo_news_context(title, hours=24):
|
def fetch_duckduckgo_news_context(title, hours=24):
|
||||||
|
for attempt in range(MAX_RETRIES):
|
||||||
try:
|
try:
|
||||||
with DDGS() as ddgs:
|
with DDGS() as ddgs:
|
||||||
results = ddgs.news(f"{title} news", timelimit="d", max_results=5)
|
results = ddgs.news(f"{title} news", timelimit="d", max_results=5)
|
||||||
@@ -196,7 +209,7 @@ def fetch_duckduckgo_news_context(title, hours=24):
|
|||||||
if '+00:00' in date_str:
|
if '+00:00' in date_str:
|
||||||
dt = datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%S+00:00").replace(tzinfo=timezone.utc)
|
dt = datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%S+00:00").replace(tzinfo=timezone.utc)
|
||||||
else:
|
else:
|
||||||
dt = datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
dt = datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%S%Z").replace(tzinfo=timezone.utc)
|
||||||
if dt > (datetime.now(timezone.utc) - timedelta(hours=24)):
|
if dt > (datetime.now(timezone.utc) - timedelta(hours=24)):
|
||||||
titles.append(r["title"].lower())
|
titles.append(r["title"].lower())
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -206,15 +219,19 @@ def fetch_duckduckgo_news_context(title, hours=24):
|
|||||||
logging.info(f"DuckDuckGo News context for '{title}': {context}")
|
logging.info(f"DuckDuckGo News context for '{title}': {context}")
|
||||||
return context
|
return context
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"DuckDuckGo News context fetch failed for '{title}': {e}")
|
logging.warning(f"DuckDuckGo News context fetch failed for '{title}' (attempt {attempt + 1}): {e}")
|
||||||
|
if attempt < MAX_RETRIES - 1:
|
||||||
|
time.sleep(RETRY_BACKOFF * (2 ** attempt))
|
||||||
|
continue
|
||||||
|
logging.error(f"Failed to fetch DuckDuckGo News context for '{title}' after {MAX_RETRIES} attempts")
|
||||||
return title
|
return title
|
||||||
|
|
||||||
def curate_from_rss():
|
def curate_from_rss():
|
||||||
articles = fetch_rss_feeds() # Corrected from fetch_rss_articles to fetch_rss_feeds
|
try:
|
||||||
|
articles = fetch_rss_feeds()
|
||||||
if not articles:
|
if not articles:
|
||||||
print("No RSS articles available")
|
|
||||||
logging.info("No RSS articles available")
|
logging.info("No RSS articles available")
|
||||||
return None, None, random.randint(600, 1800)
|
return None, None, False # Continue running
|
||||||
|
|
||||||
attempts = 0
|
attempts = 0
|
||||||
max_attempts = 10
|
max_attempts = 10
|
||||||
@@ -223,21 +240,18 @@ def curate_from_rss():
|
|||||||
title = article["title"]
|
title = article["title"]
|
||||||
link = article["link"]
|
link = article["link"]
|
||||||
summary = article.get("summary", "")
|
summary = article.get("summary", "")
|
||||||
source_name = article.get("feed_title", "Unknown Source") # Adjusted to match fetch_rss_feeds output
|
source_name = article.get("feed_title", "Unknown Source")
|
||||||
original_source = f'<a href="{link}">{source_name}</a>'
|
original_source = f'<a href="{link}">{source_name}</a>'
|
||||||
|
|
||||||
if title in posted_titles:
|
if title in posted_titles:
|
||||||
print(f"Skipping already posted article: {title}")
|
|
||||||
logging.info(f"Skipping already posted article: {title}")
|
logging.info(f"Skipping already posted article: {title}")
|
||||||
attempts += 1
|
attempts += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
print(f"Trying RSS Article: {title} from {source_name}")
|
|
||||||
logging.info(f"Trying RSS Article: {title} from {source_name}")
|
logging.info(f"Trying RSS Article: {title} from {source_name}")
|
||||||
|
|
||||||
image_query, relevance_keywords, main_topic, skip = smart_image_and_filter(title, summary)
|
image_query, relevance_keywords, main_topic, skip = smart_image_and_filter(title, summary)
|
||||||
if skip:
|
if skip:
|
||||||
print(f"Skipping filtered RSS article: {title}")
|
|
||||||
logging.info(f"Skipping filtered RSS article: {title}")
|
logging.info(f"Skipping filtered RSS article: {title}")
|
||||||
attempts += 1
|
attempts += 1
|
||||||
continue
|
continue
|
||||||
@@ -247,7 +261,6 @@ def curate_from_rss():
|
|||||||
interest_score = is_interesting(scoring_content)
|
interest_score = is_interesting(scoring_content)
|
||||||
logging.info(f"Interest score for '{title}': {interest_score}")
|
logging.info(f"Interest score for '{title}': {interest_score}")
|
||||||
if interest_score < 6:
|
if interest_score < 6:
|
||||||
print(f"RSS Interest Too Low: {interest_score}")
|
|
||||||
logging.info(f"RSS Interest Too Low: {interest_score}")
|
logging.info(f"RSS Interest Too Low: {interest_score}")
|
||||||
attempts += 1
|
attempts += 1
|
||||||
continue
|
continue
|
||||||
@@ -311,6 +324,10 @@ def curate_from_rss():
|
|||||||
interest_score=interest_score,
|
interest_score=interest_score,
|
||||||
should_post_tweet=True
|
should_post_tweet=True
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Failed to post to WordPress for '{title}': {e}", exc_info=True)
|
||||||
|
attempts += 1
|
||||||
|
continue
|
||||||
finally:
|
finally:
|
||||||
is_posting = False
|
is_posting = False
|
||||||
|
|
||||||
@@ -336,6 +353,8 @@ def curate_from_rss():
|
|||||||
post_id=post_id,
|
post_id=post_id,
|
||||||
should_post_tweet=False
|
should_post_tweet=False
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Failed to update WordPress post '{title}' with share links: {e}", exc_info=True)
|
||||||
finally:
|
finally:
|
||||||
is_posting = False
|
is_posting = False
|
||||||
|
|
||||||
@@ -349,25 +368,39 @@ def curate_from_rss():
|
|||||||
used_images.add(image_url)
|
used_images.add(image_url)
|
||||||
logging.info(f"Saved image '{image_url}' to {USED_IMAGES_FILE}")
|
logging.info(f"Saved image '{image_url}' to {USED_IMAGES_FILE}")
|
||||||
|
|
||||||
print(f"***** SUCCESS: Posted '{post_data['title']}' (ID: {post_id}) from RSS *****")
|
|
||||||
logging.info(f"***** SUCCESS: Posted '{post_data['title']}' (ID: {post_id}) from RSS *****")
|
logging.info(f"***** SUCCESS: Posted '{post_data['title']}' (ID: {post_id}) from RSS *****")
|
||||||
return post_data, category, random.randint(0, 1800)
|
return post_data, category, True # Run again immediately
|
||||||
|
|
||||||
attempts += 1
|
attempts += 1
|
||||||
logging.info(f"WP posting failed for '{post_data['title']}'")
|
logging.info(f"WP posting failed for '{post_data['title']}'")
|
||||||
|
|
||||||
print("No interesting RSS article found after attempts")
|
|
||||||
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)
|
return None, None, False # Wait before running again
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Unexpected error in curate_from_rss: {e}", exc_info=True)
|
||||||
|
return None, None, False
|
||||||
|
|
||||||
def run_rss_automator():
|
def run_rss_automator():
|
||||||
print(f"{datetime.now(timezone.utc)} - INFO - ***** RSS Automator Launched *****")
|
lock_fd = None
|
||||||
|
try:
|
||||||
|
lock_fd = acquire_lock()
|
||||||
logging.info("***** RSS Automator Launched *****")
|
logging.info("***** RSS Automator Launched *****")
|
||||||
post_data, category, sleep_time = curate_from_rss()
|
post_data, category, should_continue = curate_from_rss()
|
||||||
print(f"Sleeping for {sleep_time}s")
|
if not post_data:
|
||||||
logging.info(f"Completed run with sleep time: {sleep_time} seconds")
|
logging.info("No postable RSS article found")
|
||||||
time.sleep(sleep_time)
|
else:
|
||||||
return post_data, category, sleep_time
|
logging.info("Completed RSS run")
|
||||||
|
return post_data, category, should_continue
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Fatal error in run_rss_automator: {e}", exc_info=True)
|
||||||
|
return None, None, False
|
||||||
|
finally:
|
||||||
|
if lock_fd:
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||||
|
lock_fd.close()
|
||||||
|
os.remove(LOCK_FILE) if os.path.exists(LOCK_FILE) else None
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
run_rss_automator()
|
setup_logging()
|
||||||
|
post_data, category, should_continue = run_rss_automator()
|
||||||
|
# Remove sleep timer, let manage_scripts.sh control execution
|
||||||
|
logging.info(f"Run completed, should_continue: {should_continue}")
|
||||||
+205
-25
@@ -1,36 +1,153 @@
|
|||||||
import random
|
# foodie_engagement_tweet.py
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import random
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import fcntl
|
||||||
|
import os
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from openai import OpenAI # Add this import
|
from openai import OpenAI
|
||||||
from foodie_utils import post_tweet, AUTHORS, SUMMARY_MODEL
|
from foodie_utils import post_tweet, AUTHORS, SUMMARY_MODEL, load_post_counts, save_post_counts
|
||||||
from foodie_config import X_API_CREDENTIALS
|
from foodie_config import X_API_CREDENTIALS, AUTHOR_BACKGROUNDS_FILE
|
||||||
from dotenv import load_dotenv # Add this import
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# Setup logging
|
|
||||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
||||||
|
|
||||||
# Load environment variables
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
# Initialize OpenAI client
|
LOCK_FILE = "/home/shane/foodie_automator/locks/foodie_engagement_tweet.lock"
|
||||||
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
LOG_FILE = "/home/shane/foodie_automator/logs/foodie_engagement_tweet.log"
|
||||||
|
REFERENCE_DATE_FILE = "/home/shane/foodie_automator/engagement_reference_date.json"
|
||||||
|
LOG_PRUNE_DAYS = 30
|
||||||
|
MAX_RETRIES = 3
|
||||||
|
RETRY_BACKOFF = 2
|
||||||
|
|
||||||
|
def setup_logging():
|
||||||
|
"""Initialize logging with pruning of old logs."""
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
|
||||||
|
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 = []
|
||||||
|
malformed_count = 0
|
||||||
|
for line in lines:
|
||||||
|
if len(line) < 19 or not line[:19].replace('-', '').replace(':', '').replace(' ', '').isdigit():
|
||||||
|
malformed_count += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
timestamp = datetime.strptime(line[:19], '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc)
|
||||||
|
if timestamp > cutoff:
|
||||||
|
pruned_lines.append(line)
|
||||||
|
except ValueError:
|
||||||
|
malformed_count += 1
|
||||||
|
continue
|
||||||
|
if malformed_count > 0:
|
||||||
|
logging.info(f"Skipped {malformed_count} malformed log lines during pruning")
|
||||||
|
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.getLogger("openai").setLevel(logging.WARNING)
|
||||||
|
logging.info("Logging initialized for foodie_engagement_tweet.py")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to setup logging: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def acquire_lock():
|
||||||
|
"""Acquire a lock to prevent concurrent runs."""
|
||||||
|
os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
|
||||||
|
lock_fd = open(LOCK_FILE, 'w')
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
lock_fd.write(str(os.getpid()))
|
||||||
|
lock_fd.flush()
|
||||||
|
return lock_fd
|
||||||
|
except IOError:
|
||||||
|
logging.info("Another instance of foodie_engagement_tweet.py is running")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
def signal_handler(sig, frame):
|
||||||
|
"""Handle termination signals gracefully."""
|
||||||
|
logging.info("Received termination signal, exiting...")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
|
||||||
|
# Initialize OpenAI client
|
||||||
|
try:
|
||||||
|
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
||||||
|
if not os.getenv("OPENAI_API_KEY"):
|
||||||
|
logging.error("OPENAI_API_KEY is not set in environment variables")
|
||||||
|
raise ValueError("OPENAI_API_KEY is required")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Failed to initialize OpenAI client: {e}", exc_info=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Load author backgrounds
|
||||||
|
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}", exc_info=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def get_reference_date():
|
||||||
|
"""Load or initialize the reference date for the 2-day interval."""
|
||||||
|
os.makedirs(os.path.dirname(REFERENCE_DATE_FILE), exist_ok=True)
|
||||||
|
if os.path.exists(REFERENCE_DATE_FILE):
|
||||||
|
try:
|
||||||
|
with open(REFERENCE_DATE_FILE, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
reference_date = datetime.fromisoformat(data["reference_date"]).replace(tzinfo=timezone.utc)
|
||||||
|
logging.info(f"Loaded reference date: {reference_date.date()}")
|
||||||
|
return reference_date
|
||||||
|
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
||||||
|
logging.error(f"Failed to load reference date from {REFERENCE_DATE_FILE}: {e}. Initializing new date.")
|
||||||
|
|
||||||
|
# Initialize with current date (start of day)
|
||||||
|
reference_date = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
try:
|
||||||
|
with open(REFERENCE_DATE_FILE, 'w') as f:
|
||||||
|
json.dump({"reference_date": reference_date.isoformat()}, f)
|
||||||
|
logging.info(f"Initialized reference date: {reference_date.date()}")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Failed to save reference date to {REFERENCE_DATE_FILE}: {e}. Using current date.")
|
||||||
|
return reference_date
|
||||||
|
|
||||||
def generate_engagement_tweet(author):
|
def generate_engagement_tweet(author):
|
||||||
# Fetch x_username from X_API_CREDENTIALS
|
"""Generate an engagement tweet using author background themes."""
|
||||||
credentials = next((cred for cred in X_API_CREDENTIALS if cred["username"] == author["username"]), None)
|
credentials = next((cred for cred in X_API_CREDENTIALS if cred["username"] == author["username"]), None)
|
||||||
if not credentials:
|
if not credentials:
|
||||||
logging.error(f"No X credentials found for {author['username']}")
|
logging.error(f"No X credentials found for {author['username']}")
|
||||||
return None
|
return None
|
||||||
author_handle = credentials["x_username"]
|
author_handle = credentials["x_username"]
|
||||||
|
|
||||||
|
background = next((bg for bg in AUTHOR_BACKGROUNDS if bg["username"] == author["username"]), {})
|
||||||
|
if not background or "engagement_themes" not in background:
|
||||||
|
logging.warning(f"No background or engagement themes found for {author['username']}")
|
||||||
|
theme = "food trends"
|
||||||
|
else:
|
||||||
|
theme = random.choice(background["engagement_themes"])
|
||||||
|
|
||||||
prompt = (
|
prompt = (
|
||||||
f"Generate a concise tweet (under 280 characters) for {author_handle}. "
|
f"Generate a concise tweet (under 280 characters) for {author_handle}. "
|
||||||
f"Create an engaging food-related question or statement to spark interaction. "
|
f"Create an engaging question or statement about {theme} to spark interaction. "
|
||||||
f"Include a call to action to follow {author_handle} or like the tweet, and mention InsiderFoodie.com with a link to https://insiderfoodie.com. "
|
f"Include a call to action to follow {author_handle} or like the tweet, and mention InsiderFoodie.com with a link to https://insiderfoodie.com. "
|
||||||
f"Avoid using the word 'elevate'—use more humanized language like 'level up' or 'bring to life'. "
|
f"Avoid using the word 'elevate'—use more humanized language like 'level up' or 'bring to life'. "
|
||||||
f"Do not include emojis, hashtags, or reward-driven incentives (e.g., giveaways)."
|
f"Do not include emojis, hashtags, or reward-driven incentives (e.g., giveaways)."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for attempt in range(MAX_RETRIES):
|
||||||
try:
|
try:
|
||||||
response = client.chat.completions.create(
|
response = client.chat.completions.create(
|
||||||
model=SUMMARY_MODEL,
|
model=SUMMARY_MODEL,
|
||||||
@@ -44,40 +161,103 @@ def generate_engagement_tweet(author):
|
|||||||
tweet = response.choices[0].message.content.strip()
|
tweet = response.choices[0].message.content.strip()
|
||||||
if len(tweet) > 280:
|
if len(tweet) > 280:
|
||||||
tweet = tweet[:277] + "..."
|
tweet = tweet[:277] + "..."
|
||||||
|
logging.debug(f"Generated engagement tweet: {tweet}")
|
||||||
return tweet
|
return tweet
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"Failed to generate engagement tweet for {author['username']}: {e}")
|
logging.warning(f"Failed to generate engagement tweet for {author['username']} (attempt {attempt + 1}): {e}")
|
||||||
# Fallback templates
|
if attempt < MAX_RETRIES - 1:
|
||||||
|
time.sleep(RETRY_BACKOFF * (2 ** attempt))
|
||||||
|
else:
|
||||||
|
logging.error(f"Failed to generate engagement tweet after {MAX_RETRIES} attempts")
|
||||||
engagement_templates = [
|
engagement_templates = [
|
||||||
f"Whats the most mouthwatering dish youve seen this week Share below and follow {author_handle} for more foodie ideas on InsiderFoodie.com Link: https://insiderfoodie.com",
|
f"What's the most mouthwatering {theme} you've seen this week? Share below and follow {author_handle} for more on InsiderFoodie.com! Link: https://insiderfoodie.com",
|
||||||
f"Food lovers unite Whats your go to comfort food Tell us and like this tweet for more tasty ideas from {author_handle} on InsiderFoodie.com Link: https://insiderfoodie.com",
|
f"{theme.capitalize()} lovers unite! What's your go-to pick? Tell us and like this tweet for more from {author_handle} on InsiderFoodie.com! Link: https://insiderfoodie.com",
|
||||||
f"Ever tried a dish that looked too good to eat Share your favorites and follow {author_handle} for more culinary trends on InsiderFoodie.com Link: https://insiderfoodie.com",
|
f"Ever tried a {theme} that blew your mind? Share your favorites and follow {author_handle} for more on InsiderFoodie.com! Link: https://insiderfoodie.com",
|
||||||
f"What food trend are you loving right now Let us know and like this tweet to keep up with {author_handle} on InsiderFoodie.com Link: https://insiderfoodie.com"
|
f"What {theme} trend are you loving right now? Let us know and like this tweet to keep up with {author_handle} on InsiderFoodie.com! Link: https://insiderfoodie.com"
|
||||||
]
|
]
|
||||||
template = random.choice(engagement_templates)
|
template = random.choice(engagement_templates)
|
||||||
|
logging.info(f"Using fallback engagement tweet: {template}")
|
||||||
return template
|
return template
|
||||||
|
|
||||||
def post_engagement_tweet():
|
def post_engagement_tweet():
|
||||||
# Reference date for calculating the 2-day interval
|
"""Post engagement tweets for authors every 2 days."""
|
||||||
reference_date = datetime(2025, 4, 29, tzinfo=timezone.utc) # Starting from April 29, 2025
|
try:
|
||||||
current_date = datetime.now(timezone.utc)
|
logging.info("Starting foodie_engagement_tweet.py")
|
||||||
|
print("Starting foodie_engagement_tweet.py")
|
||||||
|
|
||||||
# Calculate the number of days since the reference date
|
# Get reference date
|
||||||
|
reference_date = get_reference_date()
|
||||||
|
current_date = datetime.now(timezone.utc)
|
||||||
days_since_reference = (current_date - reference_date).days
|
days_since_reference = (current_date - reference_date).days
|
||||||
|
logging.info(f"Days since reference date ({reference_date.date()}): {days_since_reference}")
|
||||||
|
print(f"Days since reference date ({reference_date.date()}): {days_since_reference}")
|
||||||
|
|
||||||
# Post only if the number of days since the reference date is divisible by 2
|
# Post only if the number of days since the reference date is divisible by 2
|
||||||
if days_since_reference % 2 == 0:
|
if days_since_reference % 2 == 0:
|
||||||
logging.info("Today is an engagement tweet day (every 2 days). Posting...")
|
logging.info("Today is an engagement tweet day (every 2 days). Posting...")
|
||||||
|
print("Today is an engagement tweet day (every 2 days). Posting...")
|
||||||
|
|
||||||
|
# Load post counts to check limits
|
||||||
|
post_counts = load_post_counts()
|
||||||
|
|
||||||
for author in AUTHORS:
|
for author in AUTHORS:
|
||||||
|
try:
|
||||||
|
# Check post limits
|
||||||
|
author_count = next((entry for entry in post_counts if entry["username"] == author["username"]), None)
|
||||||
|
if not author_count:
|
||||||
|
logging.error(f"No post count entry for {author['username']}, skipping")
|
||||||
|
continue
|
||||||
|
if author_count["monthly_count"] >= 500:
|
||||||
|
logging.warning(f"Monthly post limit (500) reached for {author['username']}, skipping")
|
||||||
|
continue
|
||||||
|
if author_count["daily_count"] >= 20:
|
||||||
|
logging.warning(f"Daily post limit (20) reached for {author['username']}, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
tweet = generate_engagement_tweet(author)
|
tweet = generate_engagement_tweet(author)
|
||||||
|
if not tweet:
|
||||||
|
logging.error(f"Failed to generate engagement tweet for {author['username']}, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
logging.info(f"Posting engagement tweet for {author['username']}: {tweet}")
|
logging.info(f"Posting engagement tweet for {author['username']}: {tweet}")
|
||||||
|
print(f"Posting engagement tweet for {author['username']}: {tweet}")
|
||||||
if post_tweet(author, tweet):
|
if post_tweet(author, tweet):
|
||||||
logging.info(f"Successfully posted engagement tweet for {author['username']}")
|
logging.info(f"Successfully posted engagement tweet for {author['username']}")
|
||||||
|
# Update post counts
|
||||||
|
author_count["monthly_count"] += 1
|
||||||
|
author_count["daily_count"] += 1
|
||||||
|
save_post_counts(post_counts)
|
||||||
else:
|
else:
|
||||||
logging.warning(f"Failed to post engagement tweet for {author['username']}")
|
logging.warning(f"Failed to post engagement tweet for {author['username']}")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error posting engagement tweet for {author['username']}: {e}", exc_info=True)
|
||||||
|
continue
|
||||||
else:
|
else:
|
||||||
logging.info("Today is not an engagement tweet day (every 2 days). Skipping...")
|
logging.info(f"Today is not an engagement tweet day (every 2 days). Days since reference: {days_since_reference}. Skipping...")
|
||||||
|
print(f"Today is not an engagement tweet day (every 2 days). Days since reference: {days_since_reference}. Skipping...")
|
||||||
|
|
||||||
|
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."""
|
||||||
|
lock_fd = None
|
||||||
|
try:
|
||||||
|
lock_fd = acquire_lock()
|
||||||
|
setup_logging()
|
||||||
|
post_engagement_tweet()
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Fatal error in main: {e}", exc_info=True)
|
||||||
|
print(f"Fatal error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
if lock_fd:
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||||
|
lock_fd.close()
|
||||||
|
os.remove(LOCK_FILE) if os.path.exists(LOCK_FILE) else None
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
post_engagement_tweet()
|
main()
|
||||||
+188
-42
@@ -1,94 +1,134 @@
|
|||||||
|
# foodie_weekly_thread.py
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import fcntl
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import tweepy
|
||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
from foodie_utils import post_tweet, AUTHORS, SUMMARY_MODEL
|
from foodie_utils import post_tweet, AUTHORS, SUMMARY_MODEL
|
||||||
from foodie_config import X_API_CREDENTIALS
|
from foodie_config import X_API_CREDENTIALS
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
import tweepy
|
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
# Logging configuration
|
LOCK_FILE = "/home/shane/foodie_automator/locks/foodie_weekly_thread.lock"
|
||||||
LOG_FILE = "/home/shane/foodie_automator/foodie_weekly_thread.log"
|
LOG_FILE = "/home/shane/foodie_automator/logs/foodie_weekly_thread.log"
|
||||||
LOG_PRUNE_DAYS = 30
|
LOG_PRUNE_DAYS = 30
|
||||||
|
MAX_RETRIES = 3
|
||||||
|
RETRY_BACKOFF = 2
|
||||||
|
RECENT_POSTS_FILE = "/home/shane/foodie_automator/recent_posts.json"
|
||||||
|
|
||||||
def setup_logging():
|
def setup_logging():
|
||||||
|
"""Initialize logging with pruning of old logs."""
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
|
||||||
if os.path.exists(LOG_FILE):
|
if os.path.exists(LOG_FILE):
|
||||||
with open(LOG_FILE, 'r') as f:
|
with open(LOG_FILE, 'r') as f:
|
||||||
lines = f.readlines()
|
lines = f.readlines()
|
||||||
cutoff = datetime.now(timezone.utc) - timedelta(days=LOG_PRUNE_DAYS)
|
cutoff = datetime.now(timezone.utc) - timedelta(days=LOG_PRUNE_DAYS)
|
||||||
pruned_lines = []
|
pruned_lines = []
|
||||||
|
malformed_count = 0
|
||||||
for line in lines:
|
for line in lines:
|
||||||
|
if len(line) < 19 or not line[:19].replace('-', '').replace(':', '').replace(' ', '').isdigit():
|
||||||
|
malformed_count += 1
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
timestamp = datetime.strptime(line[:19], '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc)
|
timestamp = datetime.strptime(line[:19], '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc)
|
||||||
if timestamp > cutoff:
|
if timestamp > cutoff:
|
||||||
pruned_lines.append(line)
|
pruned_lines.append(line)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
malformed_count += 1
|
||||||
continue
|
continue
|
||||||
|
if malformed_count > 0:
|
||||||
|
logging.info(f"Skipped {malformed_count} malformed log lines during pruning")
|
||||||
with open(LOG_FILE, 'w') as f:
|
with open(LOG_FILE, 'w') as f:
|
||||||
f.writelines(pruned_lines)
|
f.writelines(pruned_lines)
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
filename=LOG_FILE,
|
filename=LOG_FILE,
|
||||||
level=logging.DEBUG,
|
level=logging.INFO,
|
||||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||||
datefmt='%Y-%m-%d %H:%M:%S'
|
datefmt='%Y-%m-%d %H:%M:%S'
|
||||||
)
|
)
|
||||||
console_handler = logging.StreamHandler()
|
console_handler = logging.StreamHandler()
|
||||||
console_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
|
console_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
|
||||||
logging.getLogger().addHandler(console_handler)
|
logging.getLogger().addHandler(console_handler)
|
||||||
|
logging.getLogger("tweepy").setLevel(logging.WARNING)
|
||||||
logging.info("Logging initialized for foodie_weekly_thread.py")
|
logging.info("Logging initialized for foodie_weekly_thread.py")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to setup logging: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
setup_logging()
|
def acquire_lock():
|
||||||
|
"""Acquire a lock to prevent concurrent runs."""
|
||||||
|
os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
|
||||||
|
lock_fd = open(LOCK_FILE, 'w')
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
lock_fd.write(str(os.getpid()))
|
||||||
|
lock_fd.flush()
|
||||||
|
return lock_fd
|
||||||
|
except IOError:
|
||||||
|
logging.info("Another instance of foodie_weekly_thread.py is running")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
def signal_handler(sig, frame):
|
||||||
|
"""Handle termination signals gracefully."""
|
||||||
|
logging.info("Received termination signal, exiting...")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
|
||||||
# Initialize OpenAI client
|
# Initialize OpenAI client
|
||||||
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
try:
|
||||||
if not os.getenv("OPENAI_API_KEY"):
|
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
||||||
|
if not os.getenv("OPENAI_API_KEY"):
|
||||||
logging.error("OPENAI_API_KEY is not set in environment variables")
|
logging.error("OPENAI_API_KEY is not set in environment variables")
|
||||||
raise ValueError("OPENAI_API_KEY is required")
|
raise ValueError("OPENAI_API_KEY is required")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Failed to initialize OpenAI client: {e}", exc_info=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
# Validate X_API_CREDENTIALS and test API access
|
|
||||||
def validate_twitter_credentials():
|
def validate_twitter_credentials():
|
||||||
|
"""Validate Twitter API credentials for all authors."""
|
||||||
logging.info("Validating Twitter API credentials for all authors")
|
logging.info("Validating Twitter API credentials for all authors")
|
||||||
valid_credentials = []
|
valid_credentials = []
|
||||||
for author in AUTHORS:
|
for author in AUTHORS:
|
||||||
credentials = next((cred for cred in X_API_CREDENTIALS if cred["username"] == author["username"]), None)
|
credentials = next((cred for cred in X_API_CREDENTIALS if cred["username"] == author["username"]), None)
|
||||||
if not credentials:
|
if not credentials:
|
||||||
logging.error(f"No X credentials found for {author['username']} in X_API_CREDENTIALS")
|
logging.error(f"No X credentials found for {author['username']} in X_API_CREDENTIALS")
|
||||||
print(f"No X credentials found for {author['username']}")
|
|
||||||
continue
|
continue
|
||||||
logging.debug(f"Testing credentials for {author['username']} (handle: {credentials['x_username']})")
|
for attempt in range(MAX_RETRIES):
|
||||||
try:
|
try:
|
||||||
client = tweepy.Client(
|
twitter_client = tweepy.Client(
|
||||||
consumer_key=credentials["api_key"],
|
consumer_key=credentials["api_key"],
|
||||||
consumer_secret=credentials["api_secret"],
|
consumer_secret=credentials["api_secret"],
|
||||||
access_token=credentials["access_token"],
|
access_token=credentials["access_token"],
|
||||||
access_token_secret=credentials["access_token_secret"]
|
access_token_secret=credentials["access_token_secret"]
|
||||||
)
|
)
|
||||||
# Test API access by fetching the user's profile
|
user = twitter_client.get_me()
|
||||||
user = client.get_me()
|
logging.info(f"Credentials valid for {author['username']} (handle: {credentials['x_username']})")
|
||||||
logging.info(f"Credentials valid for {author['username']} (handle: {credentials['x_username']}, user_id: {user.data.id})")
|
|
||||||
print(f"Credentials valid for {author['username']} (handle: {credentials['x_username']})")
|
|
||||||
valid_credentials.append(credentials)
|
valid_credentials.append(credentials)
|
||||||
|
break
|
||||||
except tweepy.TweepyException as e:
|
except tweepy.TweepyException as e:
|
||||||
logging.error(f"Failed to validate credentials for {author['username']} (handle: {credentials['x_username']}): {e}")
|
logging.error(f"Failed to validate credentials for {author['username']} (attempt {attempt + 1}): {e}")
|
||||||
if hasattr(e, 'response') and e.response:
|
if attempt < MAX_RETRIES - 1:
|
||||||
logging.error(f"Twitter API response: {e.response.text}")
|
time.sleep(RETRY_BACKOFF * (2 ** attempt))
|
||||||
print(f"Failed to validate credentials for {author['username']}: {e}")
|
else:
|
||||||
|
logging.error(f"Credentials invalid for {author['username']} after {MAX_RETRIES} attempts")
|
||||||
if not valid_credentials:
|
if not valid_credentials:
|
||||||
logging.error("No valid Twitter credentials found for any author")
|
logging.error("No valid Twitter credentials found for any author")
|
||||||
raise ValueError("No valid Twitter credentials found")
|
raise ValueError("No valid Twitter credentials found")
|
||||||
return valid_credentials
|
return valid_credentials
|
||||||
|
|
||||||
# Run credential validation
|
|
||||||
validate_twitter_credentials()
|
|
||||||
|
|
||||||
RECENT_POSTS_FILE = "/home/shane/foodie_automator/recent_posts.json"
|
|
||||||
|
|
||||||
def load_recent_posts():
|
def load_recent_posts():
|
||||||
|
"""Load and deduplicate posts from recent_posts.json."""
|
||||||
posts = []
|
posts = []
|
||||||
unique_posts = {}
|
unique_posts = {}
|
||||||
logging.debug(f"Attempting to load posts from {RECENT_POSTS_FILE}")
|
logging.debug(f"Attempting to load posts from {RECENT_POSTS_FILE}")
|
||||||
@@ -131,13 +171,15 @@ def load_recent_posts():
|
|||||||
continue
|
continue
|
||||||
logging.info(f"Loaded {len(posts)} unique posts from {RECENT_POSTS_FILE} (after deduplication)")
|
logging.info(f"Loaded {len(posts)} unique posts from {RECENT_POSTS_FILE} (after deduplication)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to load {RECENT_POSTS_FILE}: {e}")
|
logging.error(f"Failed to load {RECENT_POSTS_FILE}: {e}", exc_info=True)
|
||||||
|
return posts
|
||||||
|
|
||||||
if not posts:
|
if not posts:
|
||||||
logging.warning(f"No valid posts loaded from {RECENT_POSTS_FILE}")
|
logging.warning(f"No valid posts loaded from {RECENT_POSTS_FILE}")
|
||||||
return posts
|
return posts
|
||||||
|
|
||||||
def filter_posts_for_week(posts, start_date, end_date):
|
def filter_posts_for_week(posts, start_date, end_date):
|
||||||
|
"""Filter posts within the specified week."""
|
||||||
filtered_posts = []
|
filtered_posts = []
|
||||||
logging.debug(f"Filtering {len(posts)} posts for range {start_date} to {end_date}")
|
logging.debug(f"Filtering {len(posts)} posts for range {start_date} to {end_date}")
|
||||||
|
|
||||||
@@ -155,6 +197,7 @@ def filter_posts_for_week(posts, start_date, end_date):
|
|||||||
return filtered_posts
|
return filtered_posts
|
||||||
|
|
||||||
def generate_intro_tweet(author):
|
def generate_intro_tweet(author):
|
||||||
|
"""Generate an intro tweet for the weekly thread."""
|
||||||
credentials = next((cred for cred in X_API_CREDENTIALS if cred["username"] == author["username"]), None)
|
credentials = next((cred for cred in X_API_CREDENTIALS if cred["username"] == author["username"]), None)
|
||||||
if not credentials:
|
if not credentials:
|
||||||
logging.error(f"No X credentials found for {author['username']}")
|
logging.error(f"No X credentials found for {author['username']}")
|
||||||
@@ -170,6 +213,7 @@ def generate_intro_tweet(author):
|
|||||||
f"Do not include emojis, hashtags, or reward-driven incentives (e.g., giveaways)."
|
f"Do not include emojis, hashtags, or reward-driven incentives (e.g., giveaways)."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for attempt in range(MAX_RETRIES):
|
||||||
try:
|
try:
|
||||||
response = client.chat.completions.create(
|
response = client.chat.completions.create(
|
||||||
model=SUMMARY_MODEL,
|
model=SUMMARY_MODEL,
|
||||||
@@ -186,17 +230,76 @@ def generate_intro_tweet(author):
|
|||||||
logging.debug(f"Generated intro tweet: {tweet}")
|
logging.debug(f"Generated intro tweet: {tweet}")
|
||||||
return tweet
|
return tweet
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to generate intro tweet for {author['username']}: {e}")
|
logging.warning(f"Failed to generate intro tweet for {author['username']} (attempt {attempt + 1}): {e}")
|
||||||
|
if attempt < MAX_RETRIES - 1:
|
||||||
|
time.sleep(RETRY_BACKOFF * (2 ** attempt))
|
||||||
|
else:
|
||||||
|
logging.error(f"Failed to generate intro tweet after {MAX_RETRIES} attempts")
|
||||||
fallback = (
|
fallback = (
|
||||||
f"This weeks top 10 foodie finds by {author_handle} Check out the best on InsiderFoodie.com "
|
f"This week's top 10 foodie finds by {author_handle}! Check out the best on InsiderFoodie.com. "
|
||||||
f"Follow {author_handle} for more and like this thread to stay in the loop Visit us at https://insiderfoodie.com"
|
f"Follow {author_handle} for more and like this thread to stay in the loop! Visit us at https://insiderfoodie.com"
|
||||||
)
|
)
|
||||||
logging.info(f"Using fallback intro tweet: {fallback}")
|
logging.info(f"Using fallback intro tweet: {fallback}")
|
||||||
return fallback
|
return fallback
|
||||||
|
|
||||||
|
def generate_final_cta(author):
|
||||||
|
"""Generate a final CTA tweet for the weekly thread using GPT."""
|
||||||
|
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 None
|
||||||
|
author_handle = credentials["x_username"]
|
||||||
|
logging.debug(f"Generating final CTA tweet for {author_handle}")
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
f"Generate a concise tweet (under 280 characters) for {author_handle}. "
|
||||||
|
f"Conclude a thread of their top 10 foodie posts of the week on InsiderFoodie.com. "
|
||||||
|
f"Make it engaging, value-driven, and urgent, in the style of Neil Patel. "
|
||||||
|
f"Include a call to action to visit InsiderFoodie.com and follow {author_handle}. "
|
||||||
|
f"Mention that the top 10 foodie trends are shared every Monday. "
|
||||||
|
f"Avoid using the word 'elevate'—use humanized language like 'level up' or 'bring to life'. "
|
||||||
|
f"Do not include emojis, hashtags, or reward-driven incentives (e.g., giveaways)."
|
||||||
|
)
|
||||||
|
|
||||||
|
for attempt in range(MAX_RETRIES):
|
||||||
|
try:
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
model=SUMMARY_MODEL,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": "You are a social media expert crafting engaging tweets."},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
max_tokens=100,
|
||||||
|
temperature=0.7
|
||||||
|
)
|
||||||
|
tweet = response.choices[0].message.content.strip()
|
||||||
|
if len(tweet) > 280:
|
||||||
|
tweet = tweet[:277] + "..."
|
||||||
|
logging.debug(f"Generated final CTA tweet: {tweet}")
|
||||||
|
return tweet
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"Failed to generate final CTA tweet for {author['username']} (attempt {attempt + 1}): {e}")
|
||||||
|
if attempt < MAX_RETRIES - 1:
|
||||||
|
time.sleep(RETRY_BACKOFF * (2 ** attempt))
|
||||||
|
else:
|
||||||
|
logging.error(f"Failed to generate final CTA tweet after {MAX_RETRIES} attempts")
|
||||||
|
fallback = (
|
||||||
|
f"Want more foodie insights like these? Check out insiderfoodie.com and follow {author_handle} "
|
||||||
|
f"for the world’s top 10 foodie trends every Monday. Don’t miss out!"
|
||||||
|
)
|
||||||
|
logging.info(f"Using fallback final CTA tweet: {fallback}")
|
||||||
|
return fallback
|
||||||
|
|
||||||
def post_weekly_thread():
|
def post_weekly_thread():
|
||||||
logging.info("Entering post_weekly_thread")
|
"""Post weekly threads for each author."""
|
||||||
print("Entering post_weekly_thread")
|
try:
|
||||||
|
logging.info("Starting foodie_weekly_thread.py")
|
||||||
|
print("Starting foodie_weekly_thread.py")
|
||||||
|
|
||||||
|
valid_credentials = validate_twitter_credentials()
|
||||||
|
if not valid_credentials:
|
||||||
|
logging.error("No valid Twitter credentials found, exiting")
|
||||||
|
return
|
||||||
|
|
||||||
today = datetime.now(timezone.utc)
|
today = datetime.now(timezone.utc)
|
||||||
days_to_monday = today.weekday()
|
days_to_monday = today.weekday()
|
||||||
@@ -207,8 +310,8 @@ def post_weekly_thread():
|
|||||||
print(f"Fetching posts from {start_date} to {end_date}")
|
print(f"Fetching posts from {start_date} to {end_date}")
|
||||||
|
|
||||||
all_posts = load_recent_posts()
|
all_posts = load_recent_posts()
|
||||||
print(f"Loaded {len(all_posts)} posts from recent_posts.json")
|
|
||||||
logging.info(f"Loaded {len(all_posts)} posts from recent_posts.json")
|
logging.info(f"Loaded {len(all_posts)} posts from recent_posts.json")
|
||||||
|
print(f"Loaded {len(all_posts)} posts from recent_posts.json")
|
||||||
|
|
||||||
if not all_posts:
|
if not all_posts:
|
||||||
logging.warning("No posts loaded, exiting post_weekly_thread")
|
logging.warning("No posts loaded, exiting post_weekly_thread")
|
||||||
@@ -216,8 +319,8 @@ def post_weekly_thread():
|
|||||||
return
|
return
|
||||||
|
|
||||||
weekly_posts = filter_posts_for_week(all_posts, start_date, end_date)
|
weekly_posts = filter_posts_for_week(all_posts, start_date, end_date)
|
||||||
print(f"Filtered to {len(weekly_posts)} posts for the week")
|
|
||||||
logging.info(f"Filtered to {len(weekly_posts)} posts for the week")
|
logging.info(f"Filtered to {len(weekly_posts)} posts for the week")
|
||||||
|
print(f"Filtered to {len(weekly_posts)} posts for the week")
|
||||||
|
|
||||||
if not weekly_posts:
|
if not weekly_posts:
|
||||||
logging.warning("No posts found within the week range, exiting post_weekly_thread")
|
logging.warning("No posts found within the week range, exiting post_weekly_thread")
|
||||||
@@ -233,6 +336,7 @@ def post_weekly_thread():
|
|||||||
logging.debug(f"Grouped posts by author: {list(posts_by_author.keys())}")
|
logging.debug(f"Grouped posts by author: {list(posts_by_author.keys())}")
|
||||||
|
|
||||||
for author in AUTHORS:
|
for author in AUTHORS:
|
||||||
|
try:
|
||||||
author_posts = posts_by_author.get(author["username"], [])
|
author_posts = posts_by_author.get(author["username"], [])
|
||||||
logging.info(f"Processing author {author['username']} with {len(author_posts)} posts")
|
logging.info(f"Processing author {author['username']} with {len(author_posts)} posts")
|
||||||
print(f"Processing author {author['username']} with {len(author_posts)} posts")
|
print(f"Processing author {author['username']} with {len(author_posts)} posts")
|
||||||
@@ -261,27 +365,69 @@ def post_weekly_thread():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
intro_tweet_id = intro_response.get("id")
|
intro_tweet_id = intro_response.get("id")
|
||||||
|
last_tweet_id = intro_tweet_id
|
||||||
logging.debug(f"Intro tweet posted with ID {intro_tweet_id}")
|
logging.debug(f"Intro tweet posted with ID {intro_tweet_id}")
|
||||||
|
|
||||||
for i, post in enumerate(top_posts, 1):
|
for i, post in enumerate(top_posts, 1):
|
||||||
|
try:
|
||||||
post_tweet_content = f"{i}. {post['title']} Link: {post['url']}"
|
post_tweet_content = f"{i}. {post['title']} Link: {post['url']}"
|
||||||
logging.info(f"Posting thread reply {i} for {author['username']}: {post_tweet_content}")
|
logging.info(f"Posting thread reply {i} for {author['username']}: {post_tweet_content}")
|
||||||
print(f"Posting thread reply {i} for {author['username']}: {post_tweet_content}")
|
print(f"Posting thread reply {i} for {author['username']}: {post_tweet_content}")
|
||||||
reply_response = post_tweet(author, post_tweet_content, reply_to_id=intro_tweet_id)
|
reply_response = post_tweet(author, post_tweet_content, reply_to_id=last_tweet_id)
|
||||||
if not reply_response:
|
if not reply_response:
|
||||||
logging.error(f"Failed to post thread reply {i} for {author['username']}")
|
logging.error(f"Failed to post thread reply {i} for {author['username']}")
|
||||||
else:
|
else:
|
||||||
logging.debug(f"Thread reply {i} posted with ID {reply_response.get('id')}")
|
last_tweet_id = reply_response.get("id")
|
||||||
|
logging.debug(f"Thread reply {i} posted with ID {last_tweet_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error posting thread reply {i} for {author['username']}: {e}", exc_info=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Post final CTA tweet
|
||||||
|
if last_tweet_id and top_posts: # Ensure there's a valid thread to reply to
|
||||||
|
try:
|
||||||
|
final_cta = generate_final_cta(author)
|
||||||
|
if not final_cta:
|
||||||
|
logging.error(f"Failed to generate final CTA tweet for {author['username']}, skipping")
|
||||||
|
continue
|
||||||
|
logging.info(f"Posting final CTA tweet for {author['username']}: {final_cta}")
|
||||||
|
print(f"Posting final CTA tweet for {author['username']}: {final_cta}")
|
||||||
|
cta_response = post_tweet(author, final_cta, reply_to_id=last_tweet_id)
|
||||||
|
if not cta_response:
|
||||||
|
logging.error(f"Failed to post final CTA tweet for {author['username']}")
|
||||||
|
else:
|
||||||
|
logging.debug(f"Final CTA tweet posted with ID {cta_response.get('id')}")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error posting final CTA tweet for {author['username']}: {e}", exc_info=True)
|
||||||
|
|
||||||
logging.info(f"Successfully posted weekly thread for {author['username']}")
|
logging.info(f"Successfully posted weekly thread for {author['username']}")
|
||||||
print(f"Successfully posted weekly thread for {author['username']}")
|
print(f"Successfully posted weekly thread for {author['username']}")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error processing author {author['username']}: {e}", exc_info=True)
|
||||||
|
continue
|
||||||
|
|
||||||
if __name__ == "__main__":
|
logging.info("Completed foodie_weekly_thread.py")
|
||||||
print("Starting foodie_weekly_thread.py")
|
print("Completed foodie_weekly_thread.py")
|
||||||
logging.info("Starting foodie_weekly_thread.py")
|
|
||||||
try:
|
|
||||||
post_weekly_thread()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Unexpected error in post_weekly_thread: {e}", exc_info=True)
|
logging.error(f"Unexpected error in post_weekly_thread: {e}", exc_info=True)
|
||||||
print("Completed foodie_weekly_thread.py")
|
print(f"Error in post_weekly_thread: {e}")
|
||||||
logging.info("Completed foodie_weekly_thread.py")
|
|
||||||
|
def main():
|
||||||
|
"""Main function to run the script."""
|
||||||
|
lock_fd = None
|
||||||
|
try:
|
||||||
|
lock_fd = acquire_lock()
|
||||||
|
setup_logging()
|
||||||
|
post_weekly_thread()
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Fatal error in main: {e}", exc_info=True)
|
||||||
|
print(f"Fatal error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
if lock_fd:
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||||
|
lock_fd.close()
|
||||||
|
os.remove(LOCK_FILE) if os.path.exists(LOCK_FILE) else None
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+108
-16
@@ -3,7 +3,9 @@
|
|||||||
# Directory to monitor
|
# Directory to monitor
|
||||||
BASE_DIR="/home/shane/foodie_automator"
|
BASE_DIR="/home/shane/foodie_automator"
|
||||||
CHECKSUM_FILE="$BASE_DIR/.file_checksum"
|
CHECKSUM_FILE="$BASE_DIR/.file_checksum"
|
||||||
LOG_FILE="$BASE_DIR/manage_scripts.log"
|
LOG_FILE="$BASE_DIR/logs/manage_scripts.log"
|
||||||
|
VENV_PYTHON="$BASE_DIR/venv/bin/python"
|
||||||
|
LOCK_DIR="$BASE_DIR/locks"
|
||||||
|
|
||||||
# Log function
|
# Log function
|
||||||
log() {
|
log() {
|
||||||
@@ -13,37 +15,105 @@ log() {
|
|||||||
# Calculate checksum of files (excluding logs, JSON files, and venv)
|
# Calculate checksum of files (excluding logs, JSON files, and venv)
|
||||||
calculate_checksum() {
|
calculate_checksum() {
|
||||||
find "$BASE_DIR" -type f \
|
find "$BASE_DIR" -type f \
|
||||||
-not -path "$BASE_DIR/*.log" \
|
-not -path "$BASE_DIR/logs/*" \
|
||||||
-not -path "$BASE_DIR/*.json" \
|
-not -path "$BASE_DIR/*.json" \
|
||||||
-not -path "$BASE_DIR/.file_checksum" \
|
-not -path "$BASE_DIR/.file_checksum" \
|
||||||
-not -path "$BASE_DIR/venv/*" \
|
-not -path "$BASE_DIR/venv/*" \
|
||||||
|
-not -path "$BASE_DIR/locks/*" \
|
||||||
-exec sha256sum {} \; | sort | sha256sum | awk '{print $1}'
|
-exec sha256sum {} \; | sort | sha256sum | awk '{print $1}'
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if scripts are running
|
# Check if a script is running (using lock file)
|
||||||
check_running() {
|
check_running() {
|
||||||
pgrep -f "python3.*foodie_automator" > /dev/null
|
local script_name="$1"
|
||||||
|
local lock_file="$LOCK_DIR/${script_name}.lock"
|
||||||
|
if [ -f "$lock_file" ]; then
|
||||||
|
local pid=$(cat "$lock_file")
|
||||||
|
if ps -p "$pid" > /dev/null; then
|
||||||
|
log "$script_name is already running (PID: $pid)"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log "Stale lock file found for $script_name, removing"
|
||||||
|
rm -f "$lock_file"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create lock file
|
||||||
|
create_lock() {
|
||||||
|
local script_name="$1"
|
||||||
|
local lock_file="$LOCK_DIR/${script_name}.lock"
|
||||||
|
mkdir -p "$LOCK_DIR"
|
||||||
|
echo $$ > "$lock_file"
|
||||||
|
log "Created lock file for $script_name (PID: $$)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove lock file
|
||||||
|
remove_lock() {
|
||||||
|
local script_name="$1"
|
||||||
|
local lock_file="$LOCK_DIR/${script_name}.lock"
|
||||||
|
rm -f "$lock_file"
|
||||||
|
log "Removed lock file for $script_name"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Stop scripts
|
# Stop scripts
|
||||||
stop_scripts() {
|
stop_scripts() {
|
||||||
log "Stopping scripts..."
|
log "Stopping scripts..."
|
||||||
pkill -TERM -f "python3.*foodie_automator" || true
|
for script in foodie_automator_*.py; do
|
||||||
|
if [ -f "$script" ] && [ "$script" != "foodie_weekly_thread.py" ] && [ "$script" != "foodie_engagement_tweet.py" ]; then
|
||||||
|
local script_name="${script%.py}"
|
||||||
|
pkill -TERM -f "$VENV_PYTHON.*$script_name" || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
sleep 10
|
sleep 10
|
||||||
pkill -9 -f "python3.*foodie_automator" || true
|
for script in foodie_automator_*.py; do
|
||||||
|
if [ -f "$script" ] && [ "$script" != "foodie_weekly_thread.py" ] && [ "$script" != "foodie_engagement_tweet.py" ]; then
|
||||||
|
local script_name="${script%.py}"
|
||||||
|
pkill -9 -f "$VENV_PYTHON.*$script_name" || true
|
||||||
|
remove_lock "$script_name"
|
||||||
|
fi
|
||||||
|
done
|
||||||
log "Scripts stopped."
|
log "Scripts stopped."
|
||||||
}
|
}
|
||||||
|
|
||||||
# Start scripts
|
# Start scripts
|
||||||
start_scripts() {
|
start_scripts() {
|
||||||
log "Starting scripts..."
|
log "Starting scripts..."
|
||||||
cd "$BASE_DIR"
|
cd "$BASE_DIR" || { log "Failed to change to $BASE_DIR"; exit 1; }
|
||||||
source venv/bin/activate
|
|
||||||
# Find all foodie_automator_*.py scripts and start them
|
# Source virtual environment
|
||||||
|
if [ -f "$BASE_DIR/venv/bin/activate" ]; then
|
||||||
|
source "$BASE_DIR/venv/bin/activate"
|
||||||
|
else
|
||||||
|
log "Error: Virtual environment not found at $BASE_DIR/venv"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Load .env variables
|
||||||
|
if [ -f "$BASE_DIR/.env" ]; then
|
||||||
|
export $(grep -v '^#' "$BASE_DIR/.env" | xargs)
|
||||||
|
log ".env variables loaded"
|
||||||
|
else
|
||||||
|
log "Error: .env file not found at $BASE_DIR/.env"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find and start all foodie_automator_*.py scripts (excluding weekly/engagement)
|
||||||
for script in foodie_automator_*.py; do
|
for script in foodie_automator_*.py; do
|
||||||
if [ -f "$script" ]; then
|
if [ -f "$script" ] && [ "$script" != "foodie_weekly_thread.py" ] && [ "$script" != "foodie_engagement_tweet.py" ]; then
|
||||||
|
local script_name="${script%.py}"
|
||||||
|
if ! check_running "$script_name"; then
|
||||||
log "Starting $script..."
|
log "Starting $script..."
|
||||||
nohup python3 "$script" >> "${script%.py}.log" 2>&1 &
|
create_lock "$script_name"
|
||||||
|
nohup "$VENV_PYTHON" "$script" >> "$BASE_DIR/logs/${script_name}.log" 2>&1 &
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
log "$script started successfully"
|
||||||
|
else
|
||||||
|
log "Failed to start $script"
|
||||||
|
remove_lock "$script_name"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
log "All scripts started."
|
log "All scripts started."
|
||||||
@@ -52,14 +122,34 @@ start_scripts() {
|
|||||||
# Update dependencies
|
# Update dependencies
|
||||||
update_dependencies() {
|
update_dependencies() {
|
||||||
log "Updating dependencies..."
|
log "Updating dependencies..."
|
||||||
cd "$BASE_DIR"
|
cd "$BASE_DIR" || { log "Failed to change to $BASE_DIR"; exit 1; }
|
||||||
|
|
||||||
# Create venv if it doesn't exist
|
# Create venv if it doesn't exist
|
||||||
if [ ! -d "venv" ]; then
|
if [ ! -d "venv" ]; then
|
||||||
python3 -m venv venv
|
python3 -m venv venv
|
||||||
|
log "Created new virtual environment"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Source virtual environment
|
||||||
|
if [ -f "$BASE_DIR/venv/bin/activate" ]; then
|
||||||
|
source "$BASE_DIR/venv/bin/activate"
|
||||||
|
else
|
||||||
|
log "Error: Virtual environment not found at $BASE_DIR/venv"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update pip and install requirements
|
||||||
|
"$VENV_PYTHON" -m pip install --upgrade pip
|
||||||
|
if [ -f "requirements.txt" ]; then
|
||||||
|
"$VENV_PYTHON" -m pip install -r requirements.txt || {
|
||||||
|
log "Failed to install requirements.txt, attempting fallback dependencies"
|
||||||
|
"$VENV_PYTHON" -m pip install requests openai beautifulsoup4 feedparser praw duckduckgo_search selenium Pillow pytesseract webdriver-manager
|
||||||
|
log "Fallback: Installed core dependencies"
|
||||||
|
}
|
||||||
|
else
|
||||||
|
log "Error: requirements.txt not found, installing core dependencies"
|
||||||
|
"$VENV_PYTHON" -m pip install requests openai beautifulsoup4 feedparser praw duckduckgo_search selenium Pillow pytesseract webdriver-manager
|
||||||
fi
|
fi
|
||||||
source venv/bin/activate
|
|
||||||
pip install --upgrade pip
|
|
||||||
pip install -r requirements.txt || (pip install requests openai beautifulsoup4 feedparser praw duckduckgo_search selenium Pillow pytesseract webdriver-manager && log "Fallback: Installed core dependencies")
|
|
||||||
log "Dependencies updated."
|
log "Dependencies updated."
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +167,7 @@ if [ "$CURRENT_CHECKSUM" != "$PREVIOUS_CHECKSUM" ]; then
|
|||||||
log "File changes detected. Previous checksum: $PREVIOUS_CHECKSUM, Current checksum: $CURRENT_CHECKSUM"
|
log "File changes detected. Previous checksum: $PREVIOUS_CHECKSUM, Current checksum: $CURRENT_CHECKSUM"
|
||||||
|
|
||||||
# Stop scripts if running
|
# Stop scripts if running
|
||||||
if check_running; then
|
if pgrep -f "$VENV_PYTHON.*foodie_automator" > /dev/null; then
|
||||||
stop_scripts
|
stop_scripts
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -93,3 +183,5 @@ if [ "$CURRENT_CHECKSUM" != "$PREVIOUS_CHECKSUM" ]; then
|
|||||||
else
|
else
|
||||||
log "No file changes detected."
|
log "No file changes detected."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
Reference in New Issue
Block a user