-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsocial_sync.py
More file actions
339 lines (281 loc) · 13.4 KB
/
social_sync.py
File metadata and controls
339 lines (281 loc) · 13.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
import os
import requests
import frontmatter
import sys
from dotenv import load_dotenv
from datetime import datetime, timedelta
from pathlib import Path
import re
from urllib.parse import urljoin
# --- Load Environment Variables ---
load_dotenv()
BUTTONDOWN_API_KEY = os.getenv("BUTTONDOWN_API_KEY")
BUTTONDOWN_EDIT = os.getenv("BUTTONDOWN_EDIT")
SYNC_PATH_STR = os.getenv("SYNC_PATH")
SITE_BASE_URL = os.getenv("SITE_BASE_URL")
# LinkedIn Credentials
LINKEDIN_ACCESS_TOKEN = os.getenv("LINKEDIN_ACCESS_TOKEN")
LINKEDIN_AUTHOR = os.getenv("LINKEDIN_AUTHOR")
# GoToSocial Credentials
GOTOSOCIAL_INSTANCE_URL = os.getenv("GOTOSOCIAL_INSTANCE_URL")
GOTOSOCIAL_ACCESS_TOKEN = os.getenv("GOTOSOCIAL_ACCESS_TOKEN")
# --- Verification ---
if not all([BUTTONDOWN_API_KEY, SYNC_PATH_STR, SITE_BASE_URL]):
raise ValueError("One or more required environment variables are missing in your .env file.")
# --- File & URL Functions ---
def find_recent_markdown_files(directory_path, days=7):
if not directory_path:
print("ERROR: SYNC_PATH is not set in your .env file.")
return []
sync_path = Path(directory_path).expanduser()
if not sync_path.is_dir():
print(f"ERROR: The SYNC_PATH '{sync_path}' is not a valid directory.")
return []
recent_files = []
time_threshold = datetime.now() - timedelta(days=days)
for file_path in sync_path.rglob("*.md"):
if "hot-fudge-daily" in file_path.as_posix():
try:
modified_time = datetime.fromtimestamp(file_path.stat().st_mtime)
if modified_time > time_threshold:
recent_files.append(file_path)
except FileNotFoundError:
continue
recent_files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return recent_files
def check_url_status(url):
"""Checks if a URL is live and returns a 200 status code."""
try:
print(f"Checking URL: {url}")
response = requests.head(url, timeout=10, allow_redirects=True)
if response.status_code == 200:
print("✅ URL is live.")
return True
else:
print(f"⚠️ URL returned status code {response.status_code}.")
return False
except requests.RequestException as e:
print(f"❌ Could not connect to URL: {e}")
return False
# --- Buttondown Functions ---
def post_to_buttondown(subject, body_content):
"""Posts content to Buttondown as a draft email."""
print("\n--- 📮 Posting to Buttondown... ---")
if not BUTTONDOWN_API_KEY:
print("❌ BUTTONDOWN_API_KEY not found.")
return
headers = {"Authorization": f"Token {BUTTONDOWN_API_KEY}", "Content-Type": "application/json"}
url = "https://api.buttondown.email/v1/emails"
editor_mode_comment = f"{BUTTONDOWN_EDIT}"
final_body = f"{editor_mode_comment}{body_content}"
payload = {"subject": subject, "body": final_body, "status": "draft", "email_type": "premium"}
try:
response = requests.post(url, headers=headers, json=payload)
if response.status_code == 201:
print(f"✅ Successfully created draft in Buttondown.")
else:
print(f"❌ Failed to create draft. Status: {response.status_code}\n Response: {response.text}")
except requests.exceptions.RequestException as e:
print(f"An error occurred during the API request: {e}")
# --- LinkedIn Functions ---
# --- UPDATED: Advanced LinkedIn Formatting Function ---
def format_for_linkedin(subject, description, markdown_content, url):
"""
Converts markdown to a LinkedIn-friendly plain text format with footnotes.
This function contains the advanced formatting logic.
"""
footnotes = []
# --- FIX 1: Check for and remove repeated description ---
text = markdown_content
if description and text.lstrip().startswith(description):
# Remove the description text and any following newlines
text = text.lstrip()[len(description):].lstrip('\n')
def link_to_footnote(match):
link_text = match.group(1) # Group 1 is [text]
link_url = match.group(2) # Group 2 is (url)
if link_text.startswith('!') or not link_url.startswith('http'):
return f"[{link_text}]({link_url})" # Ignore images or relative links
footnotes.append(link_url)
return f"{link_text} [{len(footnotes)}]"
def convert_md_table_to_list(match):
table_text = match.group(0)
lines = table_text.strip().split('\n')
if len(lines) < 3: return table_text
list_items = []
for row in lines[2:]:
columns = [col.strip() for col in row.split('|') if col.strip()]
if len(columns) >= 2:
list_items.append(f"• {' - '.join(columns)}")
return "\n".join(list_items) if list_items else ""
text = text.replace('\\*', '*').replace('\\$', '$').replace('\\_', '_')
text = re.sub(r'\{\{.*?\}\}', '', text, flags=re.IGNORECASE)
text = re.sub(r'```[\s\S]*?```', '', text)
text = re.sub(r'^\s*---\s*$', '', text, flags=re.MULTILINE)
table_pattern = re.compile(r'^\s*\|.*\|.*\n\s*\|[-|: ]+\|.*\n((?:\s*\|.*\|.*\n?)+)', re.MULTILINE)
text = table_pattern.sub(convert_md_table_to_list, text)
# --- FIX 2: More robust regex for links. Handles ')' in link text. ---
text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', link_to_footnote, text)
# Clean up daily-themed headings (adjust patterns as needed)
text = re.sub(r'#+\s*📈\s*Markets Monday.*', '📈 Markets Monday', text, flags=re.IGNORECASE)
text = re.sub(r'#+\s*🔥\s*Hot Takes Tuesday.*', '🔥 Hot Takes Tuesday', text, flags=re.IGNORECASE)
text = re.sub(r'#+\s*🤪\s*Wacky Wednesday.*', '🤪 Wacky Wednesday', text, flags=re.IGNORECASE)
text = re.sub(r'#+\s*🔙\s*Throwback Thursday.*', '🔙 Throwback Thursday', text, flags=re.IGNORECASE)
text = re.sub(r'#+\s*✅\s*Final Thoughts Friday.*', '✅ Final Thoughts Friday', text, flags=re.IGNORECASE)
text = re.sub(r'#+\s*🔮\s*Sneak Peak Saturday.*', '🔮 Sneak Peak Saturday', text, flags=re.IGNORECASE)
# --- FIX 3: Better heading formatting to add spacing (from linkedin_sync.py) ---
text = re.sub(r'^#+\s*(.+)$', r'\n\n\1\n', text, flags=re.MULTILINE)
text = re.sub(r'([\.!\?])\s*([A-Z])', r'\1\n\n\2', text) # Add paragraph breaks
text = re.sub(r'(\*\*|__)', '', text) # Remove bold/italic
# --- FIX 4: Convert bullet points (this should work correctly now) ---
text = re.sub(r'^\s*[\*\-]\s*', '• ', text, flags=re.MULTILINE)
text = re.sub(r'\n{3,}', '\n\n', text).strip() # Clean up extra newlines
footnote_section = ""
if footnotes:
footnote_lines = [f"[{i+1}] {url}" for i, url in enumerate(footnotes)]
footnote_section = "\n\n---\nSources:\n" + "\n".join(footnote_lines)
return f"{subject}\n\n{description}\n\n{text}{footnote_section}\n\nRead the full post here: {url}"
def post_to_linkedin(post_content):
"""Posts the given content to LinkedIn."""
print("\n--- 🔗 Posting to LinkedIn... ---")
if not all([LINKEDIN_ACCESS_TOKEN, LINKEDIN_AUTHOR]):
print("❌ LinkedIn credentials not found in .env file.")
return
headers = {
"Authorization": f"Bearer {LINKEDIN_ACCESS_TOKEN}",
"Content-Type": "application/json",
"x-li-format": "json",
"X-Restli-Protocol-Version": "2.0.0"
}
post_data = {
"author": f"{LINKEDIN_AUTHOR}",
"lifecycleState": "PUBLISHED",
"specificContent": {
"com.linkedin.ugc.ShareContent": {
"shareCommentary": {"text": post_content},
"shareMediaCategory": "NONE"
}
},
"visibility": {"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"}
}
try:
response = requests.post("https://api.linkedin.com/v2/ugcPosts", headers=headers, json=post_data)
response.raise_for_status()
print("✅ Successfully posted to LinkedIn!")
except requests.exceptions.RequestException as e:
print(f"❌ Error posting to LinkedIn: {e}\n Response: {e.response.text}")
# --- GoToSocial Functions ---
# --- RESTORED: Original simple formatting for GoToSocial ---
def format_for_gotosocial(subject, markdown_content, url):
"""Converts markdown content to a GoToSocial-friendly plain text format."""
paragraphs = markdown_content.split('\n\n')
text = '\n\n'.join(paragraphs)
return f"{subject}\n\n{text}\n\nRead the full post here: {url}"
def post_to_gotosocial(post_content):
"""Posts the given content to GoToSocial."""
print("\n--- 🐘 Posting to GoToSocial... ---")
if not all([GOTOSOCIAL_INSTANCE_URL, GOTOSOCIAL_ACCESS_TOKEN]):
print("❌ GoToSocial credentials not found in .env file.")
return
headers = {"Authorization": f"Bearer {GOTOSOCIAL_ACCESS_TOKEN}", "Content-Type": "application/json"}
post_url = f"{GOTOSOCIAL_INSTANCE_URL}/api/v1/statuses"
post_data = {"status": post_content, "visibility": "public"}
try:
response = requests.post(post_url, headers=headers, json=post_data)
response.raise_for_status()
print("✅ Successfully posted to GoToSocial!")
except requests.exceptions.RequestException as e:
print(f"❌ Error posting to GoToSocial: {e}\n Response: {e.response.text}")
# --- Main Execution ---
def main():
"""Main function to orchestrate the publishing workflow."""
# ... (The first part of the main function is unchanged) ...
print("--- Unified Social Publishing Sync ---")
recent_files = find_recent_markdown_files(SYNC_PATH_STR)
if not recent_files:
print("No recent markdown files found in 'hot-fudge-daily' to sync.")
return
print("\n--- Recent Markdown Files (Last 7 Days) ---")
for i, file_path in enumerate(recent_files):
print(f" {i + 1}. {file_path.name}")
print("-" * 30)
try:
choice = input("Enter the number of the file to publish: ").strip()
index = int(choice) - 1
if not (0 <= index < len(recent_files)):
raise ValueError("Invalid number.")
file_to_post = recent_files[index]
except (ValueError, IndexError):
print("❌ Invalid selection. Exiting.")
return
try:
post = frontmatter.load(file_to_post)
subject = post.metadata.get('title', 'No Subject')
description = post.metadata.get('description', '')
permalink = post.metadata.get('permalink', '')
markdown_content = post.content
if not permalink:
print("❌ 'permalink' not found in frontmatter. Cannot verify URL.")
return
full_url = urljoin(SITE_BASE_URL, permalink)
if not check_url_status(full_url):
print("Post is not live yet. Please deploy your site and try again.")
return
except Exception as e:
print(f"Error reading or parsing the markdown file {file_to_post}: {e}")
return
print("\nWhich platforms do you want to post to?")
print(" 1. Buttondown")
print(" 2. LinkedIn")
print(" 3. GoToSocial")
print(" 4. All of the above")
platform_choice = input("Enter your choice (e.g., '1,3' or '4'): ").strip().lower()
if not platform_choice:
print("No platforms selected. Exiting.")
return
do_buttondown = '1' in platform_choice or '4' in platform_choice
do_linkedin = '2' in platform_choice or '4' in platform_choice
do_gotosocial = '3' in platform_choice or '4' in platform_choice
if do_buttondown:
# ... (Buttondown logic is unchanged) ...
editor_mode_comment = f"{BUTTONDOWN_EDIT}"
body_for_buttondown = f"{editor_mode_comment}\n{markdown_content}"
print("\n" + "="*50)
print(" DRY RUN for Buttondown")
print("="*50 + "\n")
print(f"Subject: {subject}")
print(f"Body (first 200 chars): {body_for_buttondown.strip()[:200]}...")
print("\n" + "="*50)
publish_choice = input(f"Do you want to create this draft in Buttondown? (y/N): ").lower()
if publish_choice == 'y':
post_to_buttondown(subject, body_for_buttondown)
else:
print("\nPublishing to Buttondown cancelled.")
if do_linkedin:
# --- CORRECTED: Call the new, specific LinkedIn formatter ---
linkedin_post = format_for_linkedin(subject, description, markdown_content, full_url)
print("\n" + "="*50)
print(" DRY RUN for LinkedIn")
print("="*50 + "\n")
print(linkedin_post)
print("\n" + "="*50)
publish_choice = input(f"Do you want to publish this to LinkedIn? (y/N): ").lower()
if publish_choice == 'y':
post_to_linkedin(linkedin_post)
else:
print("\nPublishing to LinkedIn cancelled.")
if do_gotosocial:
# --- CORRECTED: Call the original, simple GoToSocial formatter ---
gotosocial_post = format_for_gotosocial(subject, markdown_content, full_url)
print("\n" + "="*50)
print(" DRY RUN for GoToSocial")
print("="*50 + "\n")
print(gotosocial_post)
print("\n" + "="*50)
publish_choice = input(f"Do you want to publish this to GoToSocial? (y/N): ").lower()
if publish_choice == 'y':
post_to_gotosocial(gotosocial_post)
else:
print("\nPublishing to GoToSocial cancelled.")
print("\n--- Sync Complete ---")
if __name__ == "__main__":
main()