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.
@@ -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()