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,28 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>com.m13v.social-stats</string>
7
+ <key>ProgramArguments</key>
8
+ <array>
9
+ <string>/bin/bash</string>
10
+ <string>/Users/matthewdi/.claude/skills/social-autoposter/skill/stats.sh</string>
11
+ </array>
12
+ <key>StartInterval</key>
13
+ <integer>21600</integer>
14
+ <key>StandardOutPath</key>
15
+ <string>/Users/matthewdi/.claude/skills/social-autoposter/logs/launchd-stats-stdout.log</string>
16
+ <key>StandardErrorPath</key>
17
+ <string>/Users/matthewdi/.claude/skills/social-autoposter/logs/launchd-stats-stderr.log</string>
18
+ <key>EnvironmentVariables</key>
19
+ <dict>
20
+ <key>PATH</key>
21
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
22
+ <key>HOME</key>
23
+ <string>/Users/matthewdi</string>
24
+ </dict>
25
+ <key>RunAtLoad</key>
26
+ <false/>
27
+ </dict>
28
+ </plist>
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "social-autoposter",
3
+ "version": "1.0.0",
4
+ "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
+ "bin": {
6
+ "social-autoposter": "bin/cli.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "scripts/*.py",
11
+ "schema.sql",
12
+ "config.example.json",
13
+ "SKILL.md",
14
+ "skill/SKILL.md",
15
+ "skill/run.sh",
16
+ "skill/stats.sh",
17
+ "skill/engage.sh",
18
+ "setup/SKILL.md",
19
+ "launchd/",
20
+ "syncfield.sh"
21
+ ],
22
+ "keywords": [
23
+ "social-media",
24
+ "automation",
25
+ "claude",
26
+ "claude-code",
27
+ "ai-agent",
28
+ "reddit",
29
+ "twitter",
30
+ "linkedin"
31
+ ],
32
+ "author": "Matthew Diakonov",
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/m13v/social-autoposter"
37
+ },
38
+ "homepage": "https://github.com/m13v/social-autoposter",
39
+ "engines": {
40
+ "node": ">=16"
41
+ }
42
+ }
package/schema.sql ADDED
@@ -0,0 +1,82 @@
1
+ CREATE TABLE IF NOT EXISTS posts (
2
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3
+ platform TEXT NOT NULL CHECK(platform IN ('reddit', 'x', 'linkedin')),
4
+ thread_url TEXT NOT NULL,
5
+ thread_author TEXT,
6
+ thread_author_handle TEXT,
7
+ thread_title TEXT,
8
+ thread_content TEXT,
9
+ thread_engagement TEXT,
10
+ our_url TEXT,
11
+ our_content TEXT NOT NULL,
12
+ our_account TEXT NOT NULL,
13
+ posted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
14
+ discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
15
+ status TEXT DEFAULT 'active' CHECK(status IN ('active', 'inactive', 'deleted', 'removed')),
16
+ status_checked_at TIMESTAMP,
17
+ engagement_updated_at TIMESTAMP,
18
+ upvotes INTEGER,
19
+ comments_count INTEGER,
20
+ views INTEGER,
21
+ source_turn_id INTEGER,
22
+ source_summary TEXT,
23
+ top_comment_author TEXT,
24
+ top_comment_content TEXT,
25
+ top_comment_upvotes INTEGER,
26
+ top_comment_url TEXT
27
+ );
28
+
29
+ CREATE INDEX IF NOT EXISTS idx_posts_platform ON posts(platform);
30
+
31
+ CREATE TABLE IF NOT EXISTS threads (
32
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
33
+ platform TEXT NOT NULL,
34
+ url TEXT NOT NULL UNIQUE,
35
+ author TEXT,
36
+ author_handle TEXT,
37
+ title TEXT,
38
+ content TEXT,
39
+ engagement TEXT,
40
+ discovered_at DATETIME DEFAULT CURRENT_TIMESTAMP
41
+ );
42
+
43
+ CREATE TABLE IF NOT EXISTS our_posts (
44
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
45
+ thread_id INTEGER REFERENCES threads(id),
46
+ platform TEXT NOT NULL,
47
+ url TEXT,
48
+ content TEXT NOT NULL,
49
+ account TEXT,
50
+ posted_at DATETIME DEFAULT CURRENT_TIMESTAMP
51
+ );
52
+
53
+ CREATE TABLE IF NOT EXISTS thread_comments (
54
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55
+ thread_id INTEGER REFERENCES threads(id),
56
+ author TEXT,
57
+ author_handle TEXT,
58
+ content TEXT,
59
+ engagement TEXT,
60
+ discovered_at DATETIME DEFAULT CURRENT_TIMESTAMP
61
+ );
62
+
63
+ CREATE TABLE IF NOT EXISTS replies (
64
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
65
+ post_id INTEGER REFERENCES posts(id),
66
+ platform TEXT NOT NULL,
67
+ their_comment_id TEXT NOT NULL,
68
+ their_author TEXT,
69
+ their_content TEXT,
70
+ their_comment_url TEXT,
71
+ our_reply_id TEXT,
72
+ our_reply_content TEXT,
73
+ our_reply_url TEXT,
74
+ parent_reply_id INTEGER REFERENCES replies(id),
75
+ moltbook_post_uuid TEXT,
76
+ moltbook_parent_comment_uuid TEXT,
77
+ depth INTEGER DEFAULT 1,
78
+ status TEXT DEFAULT 'pending' CHECK(status IN ('pending','replied','skipped','error')),
79
+ skip_reason TEXT,
80
+ discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
81
+ replied_at TIMESTAMP
82
+ );
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env python3
2
+ """Find candidate threads to comment on via Reddit JSON API + Moltbook API.
3
+
4
+ No browser needed — uses public APIs only.
5
+ Outputs JSON array of candidate threads.
6
+
7
+ Usage:
8
+ python3 scripts/find_threads.py [--db PATH] [--subreddits r/ClaudeAI,r/programming]
9
+ python3 scripts/find_threads.py --topic "macOS automation"
10
+ """
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import re
16
+ import sqlite3
17
+ import sys
18
+ import time
19
+ import urllib.request
20
+ from datetime import datetime, timezone
21
+
22
+ DEFAULT_DB = os.path.expanduser("~/social-autoposter/social_posts.db")
23
+ CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
24
+
25
+
26
+ def load_config():
27
+ if os.path.exists(CONFIG_PATH):
28
+ with open(CONFIG_PATH) as f:
29
+ return json.load(f)
30
+ return {}
31
+
32
+
33
+ def fetch_json(url, headers=None, user_agent="social-autoposter/1.0"):
34
+ hdrs = {"User-Agent": user_agent}
35
+ if headers:
36
+ hdrs.update(headers)
37
+ req = urllib.request.Request(url, headers=hdrs)
38
+ try:
39
+ with urllib.request.urlopen(req, timeout=15) as resp:
40
+ return json.loads(resp.read())
41
+ except Exception as e:
42
+ print(f" ERROR fetching {url}: {e}", file=sys.stderr)
43
+ return None
44
+
45
+
46
+ def get_already_posted(db_path):
47
+ """Return set of thread URLs we've already posted in."""
48
+ db = sqlite3.connect(db_path)
49
+ rows = db.execute("SELECT thread_url FROM posts WHERE thread_url IS NOT NULL").fetchall()
50
+ db.close()
51
+ return {row[0] for row in rows}
52
+
53
+
54
+ def get_recent_posts(db_path, limit=5):
55
+ """Return our last N post contents for repetition checking."""
56
+ db = sqlite3.connect(db_path)
57
+ rows = db.execute("SELECT our_content FROM posts ORDER BY id DESC LIMIT ?", [limit]).fetchall()
58
+ db.close()
59
+ return [row[0] for row in rows]
60
+
61
+
62
+ def check_rate_limit(db_path, max_per_day=10):
63
+ """Return (posts_today, can_post)."""
64
+ db = sqlite3.connect(db_path)
65
+ row = db.execute(
66
+ "SELECT COUNT(*) FROM posts WHERE posted_at >= datetime('now', '-24 hours')"
67
+ ).fetchone()
68
+ db.close()
69
+ count = row[0]
70
+ return count, count < max_per_day
71
+
72
+
73
+ def fetch_reddit_threads(subreddits, sort="new", limit=10, user_agent="social-autoposter/1.0"):
74
+ """Fetch threads from subreddits via Reddit JSON API."""
75
+ threads = []
76
+ for sub in subreddits:
77
+ sub = sub.lstrip("r/")
78
+ url = f"https://old.reddit.com/r/{sub}/{sort}.json?limit={limit}"
79
+ data = fetch_json(url, user_agent=user_agent)
80
+ if not data or "data" not in data:
81
+ continue
82
+
83
+ for child in data["data"].get("children", []):
84
+ post = child.get("data", {})
85
+ created = post.get("created_utc", 0)
86
+ age_hours = (datetime.now(timezone.utc).timestamp() - created) / 3600 if created else 999
87
+
88
+ threads.append({
89
+ "platform": "reddit",
90
+ "subreddit": f"r/{sub}",
91
+ "url": f"https://old.reddit.com{post.get('permalink', '')}",
92
+ "title": post.get("title", ""),
93
+ "author": post.get("author", ""),
94
+ "score": post.get("score", 0),
95
+ "num_comments": post.get("num_comments", 0),
96
+ "age_hours": round(age_hours, 1),
97
+ "selftext": post.get("selftext", "")[:500],
98
+ })
99
+ time.sleep(5)
100
+
101
+ return threads
102
+
103
+
104
+ def fetch_moltbook_threads(api_key, limit=10):
105
+ """Fetch threads from Moltbook REST API."""
106
+ if not api_key:
107
+ return []
108
+
109
+ data = fetch_json(
110
+ f"https://www.moltbook.com/api/v1/posts?limit={limit}",
111
+ headers={"Authorization": f"Bearer {api_key}"},
112
+ )
113
+ if not data or "posts" not in data:
114
+ return []
115
+
116
+ threads = []
117
+ for post in data["posts"]:
118
+ threads.append({
119
+ "platform": "moltbook",
120
+ "url": f"https://www.moltbook.com/post/{post.get('uuid', post.get('id', ''))}",
121
+ "title": post.get("title", ""),
122
+ "author": post.get("author", {}).get("name", ""),
123
+ "score": post.get("upvotes", 0),
124
+ "num_comments": post.get("comment_count", 0),
125
+ "content": post.get("content", "")[:500],
126
+ })
127
+
128
+ return threads
129
+
130
+
131
+ def filter_threads(threads, already_posted, topic=None):
132
+ """Filter out already-posted threads and optionally filter by topic."""
133
+ filtered = []
134
+ for t in threads:
135
+ if t["url"] in already_posted:
136
+ t["skip_reason"] = "already_posted"
137
+ continue
138
+ if topic:
139
+ text = f"{t.get('title', '')} {t.get('selftext', '')} {t.get('content', '')}".lower()
140
+ if topic.lower() not in text:
141
+ continue
142
+ filtered.append(t)
143
+ return filtered
144
+
145
+
146
+ def main():
147
+ parser = argparse.ArgumentParser(description="Find candidate threads to comment on")
148
+ parser.add_argument("--db", default=None, help="Path to SQLite database")
149
+ parser.add_argument("--subreddits", default=None, help="Comma-separated subreddits (e.g. ClaudeAI,programming)")
150
+ parser.add_argument("--topic", default=None, help="Filter threads by topic keyword")
151
+ parser.add_argument("--sort", default="new", choices=["new", "hot", "top"], help="Reddit sort order")
152
+ parser.add_argument("--limit", type=int, default=10, help="Threads per subreddit")
153
+ parser.add_argument("--include-moltbook", action="store_true", help="Also search Moltbook")
154
+ args = parser.parse_args()
155
+
156
+ config = load_config()
157
+ db_path = args.db or os.path.expanduser(config.get("database", DEFAULT_DB))
158
+ subreddits = args.subreddits.split(",") if args.subreddits else config.get("subreddits", [])
159
+ reddit_username = config.get("accounts", {}).get("reddit", {}).get("username", "")
160
+ user_agent = f"social-autoposter/1.0 (u/{reddit_username})" if reddit_username else "social-autoposter/1.0"
161
+
162
+ # Rate limit check
163
+ posts_today, can_post = check_rate_limit(db_path)
164
+ if not can_post:
165
+ print(json.dumps({"error": "rate_limit", "posts_today": posts_today, "threads": []}))
166
+ sys.exit(1)
167
+
168
+ already_posted = get_already_posted(db_path)
169
+ recent_posts = get_recent_posts(db_path)
170
+
171
+ # Fetch threads
172
+ threads = fetch_reddit_threads(subreddits, sort=args.sort, limit=args.limit, user_agent=user_agent)
173
+
174
+ if args.include_moltbook:
175
+ moltbook_key = os.environ.get("MOLTBOOK_API_KEY", "")
176
+ threads.extend(fetch_moltbook_threads(moltbook_key))
177
+
178
+ # Filter
179
+ candidates = filter_threads(threads, already_posted, topic=args.topic)
180
+
181
+ output = {
182
+ "posts_today": posts_today,
183
+ "can_post": can_post,
184
+ "total_found": len(threads),
185
+ "candidates": len(candidates),
186
+ "recent_post_snippets": [p[:100] if p else "" for p in recent_posts],
187
+ "threads": candidates,
188
+ }
189
+
190
+ print(json.dumps(output, indent=2))
191
+
192
+
193
+ if __name__ == "__main__":
194
+ main()