social-autoposter 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -0
- package/SKILL.md +317 -0
- package/bin/cli.js +158 -0
- package/config.example.json +51 -0
- package/launchd/com.m13v.social-autoposter.plist +28 -0
- package/launchd/com.m13v.social-engage.plist +28 -0
- package/launchd/com.m13v.social-stats.plist +28 -0
- package/package.json +42 -0
- package/schema.sql +82 -0
- package/scripts/find_threads.py +194 -0
- package/scripts/scan_replies.py +370 -0
- package/scripts/update_stats.py +208 -0
- package/setup/SKILL.md +180 -0
- package/skill/SKILL.md +283 -0
- package/skill/engage.sh +109 -0
- package/skill/run.sh +41 -0
- package/skill/stats.sh +38 -0
- package/syncfield.sh +78 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Scan Reddit + Moltbook for new replies to our posts/comments.
|
|
3
|
+
|
|
4
|
+
Inserts into the `replies` table as 'pending' or 'skipped'.
|
|
5
|
+
No LLM needed — pure API calls.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python3 scripts/scan_replies.py [--db PATH] [--reddit-account NAME]
|
|
9
|
+
|
|
10
|
+
Reads config.json for defaults if flags are omitted.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import sqlite3
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
import urllib.request
|
|
21
|
+
from datetime import datetime, timedelta, timezone
|
|
22
|
+
|
|
23
|
+
STALENESS_DAYS = 30
|
|
24
|
+
MIN_WORDS = 5
|
|
25
|
+
DEFAULT_DB = os.path.expanduser("~/social-autoposter/social_posts.db")
|
|
26
|
+
CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_config():
|
|
30
|
+
if os.path.exists(CONFIG_PATH):
|
|
31
|
+
with open(CONFIG_PATH) as f:
|
|
32
|
+
return json.load(f)
|
|
33
|
+
return {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def word_count(text):
|
|
37
|
+
return len(text.split()) if text else 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_too_old(created_utc):
|
|
41
|
+
if not created_utc:
|
|
42
|
+
return False
|
|
43
|
+
try:
|
|
44
|
+
comment_time = datetime.fromtimestamp(float(created_utc), tz=timezone.utc)
|
|
45
|
+
return (datetime.now(timezone.utc) - comment_time) > timedelta(days=STALENESS_DAYS)
|
|
46
|
+
except (ValueError, TypeError):
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def fetch_json(url, headers=None, user_agent="social-autoposter/1.0", retries=5):
|
|
51
|
+
hdrs = {"User-Agent": user_agent}
|
|
52
|
+
if headers:
|
|
53
|
+
hdrs.update(headers)
|
|
54
|
+
req = urllib.request.Request(url, headers=hdrs)
|
|
55
|
+
for attempt in range(retries):
|
|
56
|
+
try:
|
|
57
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
58
|
+
return json.loads(resp.read())
|
|
59
|
+
except urllib.error.HTTPError as e:
|
|
60
|
+
if e.code == 429 and attempt < retries - 1:
|
|
61
|
+
wait = 30 * (attempt + 1)
|
|
62
|
+
print(f" 429 rate-limited, waiting {wait}s... ({url})")
|
|
63
|
+
time.sleep(wait)
|
|
64
|
+
continue
|
|
65
|
+
print(f" ERROR fetching {url}: {e}")
|
|
66
|
+
return None
|
|
67
|
+
except Exception as e:
|
|
68
|
+
print(f" ERROR fetching {url}: {e}")
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ReplyScanner:
|
|
73
|
+
def __init__(self, db_path, reddit_account, user_agent="social-autoposter/1.0"):
|
|
74
|
+
self.db = sqlite3.connect(db_path, timeout=30)
|
|
75
|
+
self.db.row_factory = sqlite3.Row
|
|
76
|
+
self.reddit_account = reddit_account
|
|
77
|
+
self.user_agent = user_agent
|
|
78
|
+
self.skip_authors = {"AutoModerator", "[deleted]", reddit_account}
|
|
79
|
+
self.discovered = 0
|
|
80
|
+
self.skipped = 0
|
|
81
|
+
self.errors = 0
|
|
82
|
+
|
|
83
|
+
# Ensure replies table exists
|
|
84
|
+
self.db.execute("""CREATE TABLE IF NOT EXISTS replies (
|
|
85
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
86
|
+
post_id INTEGER REFERENCES posts(id),
|
|
87
|
+
platform TEXT NOT NULL,
|
|
88
|
+
their_comment_id TEXT NOT NULL,
|
|
89
|
+
their_author TEXT,
|
|
90
|
+
their_content TEXT,
|
|
91
|
+
their_comment_url TEXT,
|
|
92
|
+
our_reply_id TEXT,
|
|
93
|
+
our_reply_content TEXT,
|
|
94
|
+
our_reply_url TEXT,
|
|
95
|
+
parent_reply_id INTEGER REFERENCES replies(id),
|
|
96
|
+
moltbook_post_uuid TEXT,
|
|
97
|
+
moltbook_parent_comment_uuid TEXT,
|
|
98
|
+
depth INTEGER DEFAULT 1,
|
|
99
|
+
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','replied','skipped','error')),
|
|
100
|
+
skip_reason TEXT,
|
|
101
|
+
discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
102
|
+
replied_at TIMESTAMP
|
|
103
|
+
)""")
|
|
104
|
+
|
|
105
|
+
def already_tracked(self, platform, comment_id):
|
|
106
|
+
row = self.db.execute(
|
|
107
|
+
"SELECT COUNT(*) FROM replies WHERE platform=? AND their_comment_id=?",
|
|
108
|
+
(platform, str(comment_id)),
|
|
109
|
+
).fetchone()
|
|
110
|
+
return row[0] > 0
|
|
111
|
+
|
|
112
|
+
def insert_reply(self, post_id, platform, comment_id, author, content, comment_url,
|
|
113
|
+
parent_reply_id=None, depth=1, status="pending", skip_reason=None,
|
|
114
|
+
moltbook_post_uuid=None, moltbook_parent_comment_uuid=None):
|
|
115
|
+
comment_id = str(comment_id)
|
|
116
|
+
if self.already_tracked(platform, comment_id):
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
self.db.execute(
|
|
120
|
+
"""INSERT INTO replies
|
|
121
|
+
(post_id, platform, their_comment_id, their_author, their_content, their_comment_url,
|
|
122
|
+
parent_reply_id, depth, status, skip_reason, moltbook_post_uuid, moltbook_parent_comment_uuid)
|
|
123
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
124
|
+
(post_id, platform, comment_id, author, content, comment_url,
|
|
125
|
+
parent_reply_id, depth, status, skip_reason, moltbook_post_uuid, moltbook_parent_comment_uuid),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if status == "pending":
|
|
129
|
+
self.discovered += 1
|
|
130
|
+
else:
|
|
131
|
+
self.skipped += 1
|
|
132
|
+
|
|
133
|
+
# Commit after each insert to avoid losing data on rate-limit crashes
|
|
134
|
+
self.db.commit()
|
|
135
|
+
|
|
136
|
+
def is_our_post(self, post):
|
|
137
|
+
"""Detect if this DB row is an original post we authored (not a comment on someone else's thread)."""
|
|
138
|
+
thread_url = post["thread_url"] or ""
|
|
139
|
+
our_url = post["our_url"] or ""
|
|
140
|
+
try:
|
|
141
|
+
thread_author = post["thread_author"] or ""
|
|
142
|
+
except (IndexError, KeyError):
|
|
143
|
+
thread_author = ""
|
|
144
|
+
# Original post: thread_url == our_url, or thread_author is our account
|
|
145
|
+
if thread_url and our_url and thread_url.rstrip("/") == our_url.rstrip("/"):
|
|
146
|
+
return True
|
|
147
|
+
if thread_author and thread_author.lower() in (self.reddit_account.lower(), f"u/{self.reddit_account}".lower()):
|
|
148
|
+
return True
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
def process_reddit_comment(self, cdata, post_id, parent_reply_id=None, depth=1):
|
|
152
|
+
"""Process a single Reddit comment and return whether it was added as pending."""
|
|
153
|
+
author = cdata.get("author", "")
|
|
154
|
+
body = cdata.get("body", "")
|
|
155
|
+
comment_id = cdata.get("id", "")
|
|
156
|
+
created = cdata.get("created_utc")
|
|
157
|
+
permalink = cdata.get("permalink", "")
|
|
158
|
+
comment_url = f"https://old.reddit.com{permalink}" if permalink else ""
|
|
159
|
+
|
|
160
|
+
if author in self.skip_authors:
|
|
161
|
+
self.insert_reply(post_id, "reddit", comment_id, author, body, comment_url,
|
|
162
|
+
parent_reply_id=parent_reply_id, depth=depth,
|
|
163
|
+
status="skipped", skip_reason="filtered_author")
|
|
164
|
+
return False
|
|
165
|
+
if body in ("[deleted]", "[removed]"):
|
|
166
|
+
self.insert_reply(post_id, "reddit", comment_id, author, body, comment_url,
|
|
167
|
+
parent_reply_id=parent_reply_id, depth=depth,
|
|
168
|
+
status="skipped", skip_reason="deleted")
|
|
169
|
+
return False
|
|
170
|
+
if word_count(body) < MIN_WORDS:
|
|
171
|
+
self.insert_reply(post_id, "reddit", comment_id, author, body, comment_url,
|
|
172
|
+
parent_reply_id=parent_reply_id, depth=depth,
|
|
173
|
+
status="skipped", skip_reason=f"too_short ({word_count(body)} words)")
|
|
174
|
+
return False
|
|
175
|
+
if is_too_old(created):
|
|
176
|
+
self.insert_reply(post_id, "reddit", comment_id, author, body, comment_url,
|
|
177
|
+
parent_reply_id=parent_reply_id, depth=depth,
|
|
178
|
+
status="skipped", skip_reason="too_old")
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
self.insert_reply(post_id, "reddit", comment_id, author, body, comment_url,
|
|
182
|
+
parent_reply_id=parent_reply_id, depth=depth)
|
|
183
|
+
print(f" NEW (depth {depth}): [{post_id}] u/{author}: {body[:80]}...")
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
def walk_comment_tree(self, children, post_id, parent_reply_id=None, depth=1, max_depth=5):
|
|
187
|
+
"""Recursively walk a Reddit comment tree, processing all non-our comments."""
|
|
188
|
+
for child in children:
|
|
189
|
+
if child.get("kind") != "t1":
|
|
190
|
+
continue
|
|
191
|
+
cdata = child.get("data", {})
|
|
192
|
+
author = cdata.get("author", "")
|
|
193
|
+
|
|
194
|
+
# For original posts: our own comments in the tree are not replies to track,
|
|
195
|
+
# but we still want to recurse into their children (replies to our replies)
|
|
196
|
+
is_ours = author.lower() == self.reddit_account.lower()
|
|
197
|
+
|
|
198
|
+
if not is_ours:
|
|
199
|
+
self.process_reddit_comment(cdata, post_id, parent_reply_id=parent_reply_id, depth=depth)
|
|
200
|
+
|
|
201
|
+
# Recurse into nested replies
|
|
202
|
+
if depth < max_depth:
|
|
203
|
+
replies_obj = cdata.get("replies")
|
|
204
|
+
if replies_obj and isinstance(replies_obj, dict):
|
|
205
|
+
nested = replies_obj.get("data", {}).get("children", [])
|
|
206
|
+
if nested:
|
|
207
|
+
self.walk_comment_tree(nested, post_id, parent_reply_id=parent_reply_id, depth=depth + 1, max_depth=max_depth)
|
|
208
|
+
|
|
209
|
+
def process_reddit_replies(self, children, post_id, parent_reply_id=None, depth=1):
|
|
210
|
+
"""Process a flat list of Reddit comments (non-recursive, used for comment-post scanning)."""
|
|
211
|
+
for child in children:
|
|
212
|
+
if child.get("kind") != "t1":
|
|
213
|
+
continue
|
|
214
|
+
cdata = child.get("data", {})
|
|
215
|
+
self.process_reddit_comment(cdata, post_id, parent_reply_id=parent_reply_id, depth=depth)
|
|
216
|
+
|
|
217
|
+
def scan_reddit(self):
|
|
218
|
+
print("Scanning Reddit posts for replies...")
|
|
219
|
+
posts = self.db.execute(
|
|
220
|
+
"SELECT id, our_url, thread_url, thread_title, thread_author FROM posts "
|
|
221
|
+
"WHERE platform='reddit' AND status='active' AND our_url IS NOT NULL AND our_url != '' AND our_url LIKE 'http%'"
|
|
222
|
+
).fetchall()
|
|
223
|
+
|
|
224
|
+
for post in posts:
|
|
225
|
+
post_id = post["id"]
|
|
226
|
+
our_url = post["our_url"]
|
|
227
|
+
is_original = self.is_our_post(post)
|
|
228
|
+
|
|
229
|
+
if is_original:
|
|
230
|
+
# Original post: fetch the post URL and scan ALL top-level comments + their trees
|
|
231
|
+
json_url = re.sub(r"www\.reddit\.com", "old.reddit.com", our_url).rstrip("/") + ".json"
|
|
232
|
+
data = fetch_json(json_url, user_agent=self.user_agent)
|
|
233
|
+
if not data or not isinstance(data, list) or len(data) < 2:
|
|
234
|
+
self.errors += 1
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
children = data[1].get("data", {}).get("children", [])
|
|
238
|
+
if children:
|
|
239
|
+
print(f" Scanning original post [{post_id}]: {post['thread_title'][:60]}... ({len(children)} top-level comments)")
|
|
240
|
+
self.walk_comment_tree(children, post_id, depth=1)
|
|
241
|
+
else:
|
|
242
|
+
# Comment on someone else's thread: fetch our comment URL and scan replies to it
|
|
243
|
+
json_url = re.sub(r"www\.reddit\.com", "old.reddit.com", our_url).rstrip("/") + ".json"
|
|
244
|
+
data = fetch_json(json_url, user_agent=self.user_agent)
|
|
245
|
+
if not data or not isinstance(data, list) or len(data) < 2:
|
|
246
|
+
self.errors += 1
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
children = data[1].get("data", {}).get("children", [])
|
|
250
|
+
if not children:
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
our_comment = children[0].get("data", {})
|
|
254
|
+
replies_obj = our_comment.get("replies")
|
|
255
|
+
if not replies_obj or not isinstance(replies_obj, dict):
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
reply_children = replies_obj.get("data", {}).get("children", [])
|
|
259
|
+
self.process_reddit_replies(reply_children, post_id)
|
|
260
|
+
|
|
261
|
+
time.sleep(8)
|
|
262
|
+
|
|
263
|
+
# Scan replies to our previous replies (infinite depth BFS)
|
|
264
|
+
print("\nScanning replies to our previous replies...")
|
|
265
|
+
replied_rows = self.db.execute(
|
|
266
|
+
"SELECT id, platform, our_reply_url, post_id, depth "
|
|
267
|
+
"FROM replies WHERE status='replied' AND our_reply_url IS NOT NULL"
|
|
268
|
+
).fetchall()
|
|
269
|
+
|
|
270
|
+
for row in replied_rows:
|
|
271
|
+
if row["platform"] != "reddit":
|
|
272
|
+
continue
|
|
273
|
+
json_url = re.sub(r"www\.reddit\.com", "old.reddit.com", row["our_reply_url"]).rstrip("/") + ".json"
|
|
274
|
+
data = fetch_json(json_url, user_agent=self.user_agent)
|
|
275
|
+
if not data or not isinstance(data, list) or len(data) < 2:
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
children = data[1].get("data", {}).get("children", [])
|
|
279
|
+
if not children:
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
our_reply_data = children[0].get("data", {})
|
|
283
|
+
replies_obj = our_reply_data.get("replies")
|
|
284
|
+
if not replies_obj or not isinstance(replies_obj, dict):
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
reply_children = replies_obj.get("data", {}).get("children", [])
|
|
288
|
+
self.process_reddit_replies(
|
|
289
|
+
reply_children, row["post_id"],
|
|
290
|
+
parent_reply_id=row["id"], depth=row["depth"] + 1,
|
|
291
|
+
)
|
|
292
|
+
time.sleep(8)
|
|
293
|
+
|
|
294
|
+
def scan_moltbook(self, api_key):
|
|
295
|
+
if not api_key:
|
|
296
|
+
print("MOLTBOOK_API_KEY not set, skipping Moltbook scan")
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
print("\nScanning Moltbook posts for replies...")
|
|
300
|
+
posts = self.db.execute(
|
|
301
|
+
"SELECT id, our_url FROM posts "
|
|
302
|
+
"WHERE platform='moltbook' AND status='active' AND our_url IS NOT NULL"
|
|
303
|
+
).fetchall()
|
|
304
|
+
|
|
305
|
+
for post in posts:
|
|
306
|
+
post_id = post["id"]
|
|
307
|
+
uuid_match = re.search(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", post["our_url"])
|
|
308
|
+
if not uuid_match:
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
data = fetch_json(
|
|
312
|
+
f"https://www.moltbook.com/api/v1/posts/{uuid_match.group()}",
|
|
313
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
314
|
+
)
|
|
315
|
+
if not data or not data.get("success"):
|
|
316
|
+
self.errors += 1
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
comments = data.get("post", {}).get("comments", [])
|
|
320
|
+
for comment in comments:
|
|
321
|
+
author = comment.get("author", {}).get("name", "")
|
|
322
|
+
content = comment.get("content", "")
|
|
323
|
+
comment_id = comment.get("uuid", comment.get("id", ""))
|
|
324
|
+
comment_url = f"https://www.moltbook.com/post/{uuid_match.group()}#comment-{comment_id}"
|
|
325
|
+
|
|
326
|
+
if word_count(content) < MIN_WORDS:
|
|
327
|
+
self.insert_reply(post_id, "moltbook", comment_id, author, content, comment_url,
|
|
328
|
+
status="skipped", skip_reason=f"too_short ({word_count(content)} words)",
|
|
329
|
+
moltbook_post_uuid=uuid_match.group())
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
self.insert_reply(post_id, "moltbook", comment_id, author, content, comment_url,
|
|
333
|
+
moltbook_post_uuid=uuid_match.group())
|
|
334
|
+
print(f" NEW: [{post_id}] {author}: {content[:80]}...")
|
|
335
|
+
|
|
336
|
+
def finish(self):
|
|
337
|
+
self.db.commit()
|
|
338
|
+
self.db.close()
|
|
339
|
+
print(f"\nScan complete: {self.discovered} new pending, {self.skipped} skipped, {self.errors} errors")
|
|
340
|
+
return {"discovered": self.discovered, "skipped": self.skipped, "errors": self.errors}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def main():
|
|
344
|
+
parser = argparse.ArgumentParser(description="Scan for replies to our social posts")
|
|
345
|
+
parser.add_argument("--db", default=None, help="Path to SQLite database")
|
|
346
|
+
parser.add_argument("--reddit-account", default=None, help="Reddit username")
|
|
347
|
+
args = parser.parse_args()
|
|
348
|
+
|
|
349
|
+
config = load_config()
|
|
350
|
+
db_path = args.db or os.path.expanduser(config.get("database", DEFAULT_DB))
|
|
351
|
+
reddit_account = args.reddit_account or config.get("accounts", {}).get("reddit", {}).get("username", "")
|
|
352
|
+
|
|
353
|
+
if not reddit_account:
|
|
354
|
+
print("ERROR: Reddit account not configured. Set it in config.json or pass --reddit-account")
|
|
355
|
+
sys.exit(1)
|
|
356
|
+
|
|
357
|
+
user_agent = f"social-autoposter/1.0 (u/{reddit_account})"
|
|
358
|
+
scanner = ReplyScanner(db_path, reddit_account, user_agent)
|
|
359
|
+
scanner.scan_reddit()
|
|
360
|
+
|
|
361
|
+
moltbook_key = os.environ.get("MOLTBOOK_API_KEY", "")
|
|
362
|
+
scanner.scan_moltbook(moltbook_key)
|
|
363
|
+
|
|
364
|
+
result = scanner.finish()
|
|
365
|
+
# Exit with code 0 if any new replies found, 1 if none
|
|
366
|
+
sys.exit(0 if result["discovered"] > 0 else 1)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
if __name__ == "__main__":
|
|
370
|
+
main()
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fetch engagement stats for Reddit + Moltbook posts via public APIs.
|
|
3
|
+
|
|
4
|
+
Updates upvotes, comments_count, thread_engagement, and status in the DB.
|
|
5
|
+
No browser needed.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python3 scripts/update_stats.py [--db PATH] [--quiet]
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import sqlite3
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
import urllib.request
|
|
19
|
+
|
|
20
|
+
DEFAULT_DB = os.path.expanduser("~/social-autoposter/social_posts.db")
|
|
21
|
+
CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_config():
|
|
25
|
+
if os.path.exists(CONFIG_PATH):
|
|
26
|
+
with open(CONFIG_PATH) as f:
|
|
27
|
+
return json.load(f)
|
|
28
|
+
return {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def fetch_json(url, headers=None, user_agent="social-autoposter/1.0"):
|
|
32
|
+
hdrs = {"User-Agent": user_agent}
|
|
33
|
+
if headers:
|
|
34
|
+
hdrs.update(headers)
|
|
35
|
+
req = urllib.request.Request(url, headers=hdrs)
|
|
36
|
+
try:
|
|
37
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
38
|
+
return json.loads(resp.read())
|
|
39
|
+
except Exception as e:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def update_reddit(db, user_agent, quiet=False):
|
|
44
|
+
posts = db.execute(
|
|
45
|
+
"SELECT id, our_url, thread_url FROM posts "
|
|
46
|
+
"WHERE platform='reddit' AND status='active' AND our_url IS NOT NULL ORDER BY id"
|
|
47
|
+
).fetchall()
|
|
48
|
+
|
|
49
|
+
total = updated = deleted = removed = errors = 0
|
|
50
|
+
results = []
|
|
51
|
+
|
|
52
|
+
for post in posts:
|
|
53
|
+
total += 1
|
|
54
|
+
post_id, our_url = post[0], post[1]
|
|
55
|
+
if not our_url or not our_url.startswith("http"):
|
|
56
|
+
errors += 1
|
|
57
|
+
continue
|
|
58
|
+
json_url = re.sub(r"www\.reddit\.com", "old.reddit.com", our_url).rstrip("/") + ".json"
|
|
59
|
+
|
|
60
|
+
response = fetch_json(json_url, user_agent=user_agent)
|
|
61
|
+
if not response or not isinstance(response, list) or len(response) < 2:
|
|
62
|
+
# Retry once
|
|
63
|
+
time.sleep(5)
|
|
64
|
+
response = fetch_json(json_url, user_agent=user_agent)
|
|
65
|
+
if not response or not isinstance(response, list) or len(response) < 2:
|
|
66
|
+
errors += 1
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
children = response[1].get("data", {}).get("children", [])
|
|
70
|
+
if not children:
|
|
71
|
+
errors += 1
|
|
72
|
+
continue
|
|
73
|
+
comment_data = children[0].get("data")
|
|
74
|
+
if not comment_data:
|
|
75
|
+
errors += 1
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
body = comment_data.get("body", "")
|
|
79
|
+
author = comment_data.get("author", "")
|
|
80
|
+
score = comment_data.get("score", 0)
|
|
81
|
+
|
|
82
|
+
if body in ("[deleted]",) or author == "[deleted]":
|
|
83
|
+
db.execute("UPDATE posts SET status='deleted', status_checked_at=datetime('now') WHERE id=?", [post_id])
|
|
84
|
+
deleted += 1
|
|
85
|
+
if not quiet:
|
|
86
|
+
print(f"DELETED [{post_id}]")
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
if body == "[removed]":
|
|
90
|
+
db.execute("UPDATE posts SET status='removed', status_checked_at=datetime('now') WHERE id=?", [post_id])
|
|
91
|
+
removed += 1
|
|
92
|
+
if not quiet:
|
|
93
|
+
print(f"REMOVED [{post_id}]")
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
thread_score = response[0].get("data", {}).get("children", [{}])[0].get("data", {}).get("score", 0)
|
|
97
|
+
thread_comments = response[0].get("data", {}).get("children", [{}])[0].get("data", {}).get("num_comments", 0)
|
|
98
|
+
thread_title = response[0].get("data", {}).get("children", [{}])[0].get("data", {}).get("title", "")[:60]
|
|
99
|
+
engagement = json.dumps({"thread_score": thread_score, "thread_comments": thread_comments})
|
|
100
|
+
|
|
101
|
+
db.execute(
|
|
102
|
+
"UPDATE posts SET upvotes=?, comments_count=?, thread_engagement=?, "
|
|
103
|
+
"engagement_updated_at=datetime('now'), status_checked_at=datetime('now') WHERE id=?",
|
|
104
|
+
[score, thread_comments, engagement, post_id],
|
|
105
|
+
)
|
|
106
|
+
updated += 1
|
|
107
|
+
results.append({"id": post_id, "score": score, "thread_score": thread_score,
|
|
108
|
+
"thread_comments": thread_comments, "title": thread_title})
|
|
109
|
+
time.sleep(5)
|
|
110
|
+
|
|
111
|
+
db.commit()
|
|
112
|
+
return {"total": total, "updated": updated, "deleted": deleted, "removed": removed,
|
|
113
|
+
"errors": errors, "results": results}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def update_moltbook(db, api_key, quiet=False):
|
|
117
|
+
if not api_key:
|
|
118
|
+
return {"skipped": True, "reason": "no_api_key"}
|
|
119
|
+
|
|
120
|
+
posts = db.execute(
|
|
121
|
+
"SELECT id, our_url FROM posts WHERE platform='moltbook' AND status='active' AND our_url IS NOT NULL ORDER BY id"
|
|
122
|
+
).fetchall()
|
|
123
|
+
|
|
124
|
+
total = updated = deleted = errors = 0
|
|
125
|
+
results = []
|
|
126
|
+
|
|
127
|
+
for post in posts:
|
|
128
|
+
total += 1
|
|
129
|
+
post_id, our_url = post[0], post[1]
|
|
130
|
+
uuid_match = re.search(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", our_url)
|
|
131
|
+
if not uuid_match:
|
|
132
|
+
errors += 1
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
data = fetch_json(
|
|
136
|
+
f"https://www.moltbook.com/api/v1/posts/{uuid_match.group()}",
|
|
137
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
138
|
+
)
|
|
139
|
+
if not data or not data.get("success"):
|
|
140
|
+
errors += 1
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
post_data = data.get("post", {})
|
|
144
|
+
if post_data.get("is_deleted"):
|
|
145
|
+
db.execute("UPDATE posts SET status='deleted', status_checked_at=datetime('now') WHERE id=?", [post_id])
|
|
146
|
+
deleted += 1
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
upvotes = post_data.get("upvotes", 0)
|
|
150
|
+
comment_count = post_data.get("comment_count", 0)
|
|
151
|
+
score = post_data.get("score", 0)
|
|
152
|
+
title = post_data.get("title", "")[:60]
|
|
153
|
+
engagement = json.dumps({"score": score, "upvotes": upvotes, "comment_count": comment_count})
|
|
154
|
+
|
|
155
|
+
db.execute(
|
|
156
|
+
"UPDATE posts SET upvotes=?, comments_count=?, thread_engagement=?, "
|
|
157
|
+
"engagement_updated_at=datetime('now'), status_checked_at=datetime('now') WHERE id=?",
|
|
158
|
+
[upvotes, comment_count, engagement, post_id],
|
|
159
|
+
)
|
|
160
|
+
updated += 1
|
|
161
|
+
results.append({"id": post_id, "upvotes": upvotes, "score": score,
|
|
162
|
+
"comments": comment_count, "title": title})
|
|
163
|
+
|
|
164
|
+
db.commit()
|
|
165
|
+
return {"total": total, "updated": updated, "deleted": deleted, "errors": errors, "results": results}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def main():
|
|
169
|
+
parser = argparse.ArgumentParser(description="Update engagement stats for social posts")
|
|
170
|
+
parser.add_argument("--db", default=None, help="Path to SQLite database")
|
|
171
|
+
parser.add_argument("--quiet", action="store_true", help="Minimal output")
|
|
172
|
+
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
173
|
+
args = parser.parse_args()
|
|
174
|
+
|
|
175
|
+
config = load_config()
|
|
176
|
+
db_path = args.db or os.path.expanduser(config.get("database", DEFAULT_DB))
|
|
177
|
+
reddit_username = config.get("accounts", {}).get("reddit", {}).get("username", "")
|
|
178
|
+
user_agent = f"social-autoposter/1.0 (u/{reddit_username})" if reddit_username else "social-autoposter/1.0"
|
|
179
|
+
|
|
180
|
+
db = sqlite3.connect(db_path, timeout=30)
|
|
181
|
+
|
|
182
|
+
reddit_stats = update_reddit(db, user_agent, quiet=args.quiet)
|
|
183
|
+
moltbook_stats = update_moltbook(db, os.environ.get("MOLTBOOK_API_KEY", ""), quiet=args.quiet)
|
|
184
|
+
|
|
185
|
+
db.close()
|
|
186
|
+
|
|
187
|
+
output = {"reddit": reddit_stats, "moltbook": moltbook_stats}
|
|
188
|
+
|
|
189
|
+
if args.json:
|
|
190
|
+
print(json.dumps(output, indent=2))
|
|
191
|
+
else:
|
|
192
|
+
r = reddit_stats
|
|
193
|
+
print(f"\nReddit: {r['total']} checked, {r['updated']} updated, "
|
|
194
|
+
f"{r['deleted']} deleted, {r['removed']} removed, {r['errors']} errors")
|
|
195
|
+
if not args.quiet and r["results"]:
|
|
196
|
+
print(f"{'ID':>4} {'Score':>5} {'Thread':>7} {'Comments':>8} Title")
|
|
197
|
+
for row in sorted(r["results"], key=lambda x: x["score"], reverse=True):
|
|
198
|
+
print(f"{row['id']:>4} {row['score']:>5} {row['thread_score']:>7} "
|
|
199
|
+
f"{row['thread_comments']:>8} {row['title']}")
|
|
200
|
+
|
|
201
|
+
if not moltbook_stats.get("skipped"):
|
|
202
|
+
m = moltbook_stats
|
|
203
|
+
print(f"\nMoltbook: {m['total']} checked, {m['updated']} updated, "
|
|
204
|
+
f"{m['deleted']} deleted, {m['errors']} errors")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == "__main__":
|
|
208
|
+
main()
|