social-autoposter 1.1.2 → 1.1.3

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/SKILL.md CHANGED
@@ -61,6 +61,8 @@ python3 ~/social-autoposter/scripts/update_stats.py --quiet
61
61
 
62
62
  ## Workflow: Post (`/social-autoposter`)
63
63
 
64
+ **There is NO posting rate limit. Do not add one, do not enforce one, do not invent one. Post as many times as needed.**
65
+
64
66
  ### 1. Find candidate threads
65
67
 
66
68
  **Option A — Script (preferred):**
@@ -111,18 +113,20 @@ curl -s -X POST -H "Authorization: Bearer $MOLTBOOK_API_KEY" -H "Content-Type: a
111
113
  -d '{"title": "...", "content": "...", "type": "text", "submolt_name": "general"}' \
112
114
  "https://www.moltbook.com/api/v1/posts"
113
115
  ```
114
- On Moltbook: write as agent ("my human" not "I"). Max 1 post per 30 min.
116
+ On Moltbook: write as agent ("my human" not "I").
115
117
  Verify: fetch post by UUID, check `verification_status` is `"verified"`.
116
118
 
117
- ### 7. Log + sync
119
+ ### 6. Log + sync
118
120
 
119
121
  ```sql
120
122
  INSERT INTO posts (platform, thread_url, thread_author, thread_author_handle,
121
123
  thread_title, thread_content, our_url, our_content, our_account,
122
- source_summary, status, posted_at)
123
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'active', NOW());
124
+ source_summary, project_name, status, posted_at)
125
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'active', NOW());
124
126
  ```
125
127
 
128
+ Set `project_name` to the matching project name from `config.json` (e.g., 'Fazm', 'Cyrano', 'Terminator'). Every post/comment MUST be labeled with its target project. If engagement is general/unrelated to any project, use 'general'.
129
+
126
130
  Use the account value from `config.json` for `our_account`.
127
131
 
128
132
  If `sync_script` is set in config.json, run it after logging.
@@ -143,11 +147,11 @@ ORDER BY posted_at DESC;
143
147
 
144
148
  **NEVER post the same or similar content to multiple subreddits.** This is the #1 AI detection red flag. Each post must be unique to its community.
145
149
 
146
- ### 3. Pick one target community
150
+ ### 2. Pick one target community
147
151
 
148
152
  Choose the single best subreddit from `config.json → subreddits` for this topic. Tailor the post to that community's culture and tone.
149
153
 
150
- ### 4. Draft the post
154
+ ### 3. Draft the post
151
155
 
152
156
  **Anti-AI-detection checklist** (must pass ALL before posting):
153
157
 
@@ -163,22 +167,22 @@ Choose the single best subreddit from `config.json → subreddits` for this topi
163
167
 
164
168
  **Read it out loud.** If it sounds like a blog post or a ChatGPT response, rewrite it.
165
169
 
166
- ### 5. Post it
170
+ ### 4. Post it
167
171
 
168
172
  **Reddit**: old.reddit.com → Submit new text post → paste title + body → submit → verify → capture permalink.
169
173
 
170
- ### 6. Log it
174
+ ### 5. Log it
171
175
 
172
176
  ```sql
173
177
  INSERT INTO posts (platform, thread_url, thread_author, thread_author_handle,
174
178
  thread_title, thread_content, our_url, our_content, our_account,
175
- source_summary, status, posted_at)
176
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'active', NOW());
179
+ source_summary, project_name, status, posted_at)
180
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'active', NOW());
177
181
  ```
178
182
 
179
- For original posts: `thread_url` = `our_url`, `thread_author` = our account from config.json.
183
+ Set `project_name` to the matching project name from `config.json`. For original posts: `thread_url` = `our_url`, `thread_author` = our account from config.json.
180
184
 
181
- ### 7. Mandatory engagement plan
185
+ ### 6. Mandatory engagement plan
182
186
 
183
187
  After posting, you MUST:
184
188
  - Check for comments within 2-4 hours
@@ -214,7 +218,7 @@ FROM replies r JOIN posts p ON r.post_id = p.id
214
218
  WHERE r.status='pending' ORDER BY r.discovered_at ASC LIMIT 10
215
219
  ```
216
220
 
217
- Draft replies: 2-4 sentences, casual, expand the topic. Apply Tiered Reply Strategy. Max 5 replies per run.
221
+ Draft replies: 2-4 sentences, casual, expand the topic. Apply Tiered Reply Strategy.
218
222
 
219
223
  Post via browser (Reddit/X) or API (Moltbook). Update:
220
224
  ```sql
@@ -224,7 +228,7 @@ UPDATE replies SET status='replied', our_reply_content=%s, our_reply_url=%s,
224
228
 
225
229
  ### Phase C: X/Twitter replies (browser required)
226
230
 
227
- Navigate to `https://x.com/notifications/mentions`. Find replies to the handle in config.json. Respond to substantive ones (max 5). Log to `replies` table.
231
+ Navigate to `https://x.com/notifications/mentions`. Find replies to the handle in config.json. Respond to substantive ones. Log to `replies` table.
228
232
 
229
233
  ---
230
234
 
@@ -251,10 +255,9 @@ Visit each post URL via browser. Check status (active/deleted/removed/inactive).
251
255
  8. **No em dashes (—).** Use commas, periods, or regular dashes (-) instead. Em dashes are the #1 "ChatGPT tell."
252
256
  9. **No markdown formatting in Reddit.** No headers (##), no bold (**text**), no numbered lists. Write in plain paragraphs.
253
257
  10. **Never cross-post.** One post per topic per community.
254
- 11. **Space posts out.** Max 1 original post per day, max 3 per week. Don't spam.
255
- 12. **Include imperfections.** Contractions, sentence fragments, casual asides, occasional lowercase.
256
- 13. **Vary your openings.** Don't always start with credentials. Sometimes just jump into the topic.
257
- 14. **Reply to comments on your posts.** Zero engagement on your own post = bot signal. Reply within 24h.
258
+ 11. **Include imperfections.** Contractions, sentence fragments, casual asides, occasional lowercase.
259
+ 12. **Vary your openings.** Don't always start with credentials. Sometimes just jump into the topic.
260
+ 13. **Reply to comments on your posts.** Zero engagement on your own post = bot signal. Reply within 24h.
258
261
 
259
262
  ### Bad vs Good (Comments)
260
263
 
@@ -286,6 +289,6 @@ GOOD body: Paragraphs, incomplete thoughts, personal details, casual tone, ends
286
289
 
287
290
  ## Database Schema
288
291
 
289
- `posts`: id, platform, thread_url, thread_title, our_url, our_content, our_account, posted_at, status, upvotes, comments_count, views, source_summary
292
+ `posts`: id, platform, thread_url, thread_title, our_url, our_content, our_account, project_name, posted_at, status, upvotes, comments_count, views, source_summary, link_edited_at, link_edit_content
290
293
 
291
294
  `replies`: id, post_id, platform, their_author, their_content, our_reply_content, status (pending|replied|skipped|error), depth
package/bin/cli.js CHANGED
@@ -49,15 +49,6 @@ function generatePlists() {
49
49
  const launchdPath = [...pathDirs].join(':');
50
50
 
51
51
  const plists = [
52
- {
53
- file: 'com.m13v.social-autoposter.plist',
54
- label: 'com.m13v.social-autoposter',
55
- script: `${DEST}/skill/run.sh`,
56
- interval: 3600,
57
- runAtLoad: true,
58
- stdoutLog: `${DEST}/skill/logs/launchd-stdout.log`,
59
- stderrLog: `${DEST}/skill/logs/launchd-stderr.log`,
60
- },
61
52
  {
62
53
  file: 'com.m13v.social-stats.plist',
63
54
  label: 'com.m13v.social-stats',
package/bin/server.js CHANGED
@@ -25,7 +25,7 @@ const JOBS = [
25
25
  { label: 'com.m13v.social-twitter', name: 'Twitter', type: 'Post', platform: 'Twitter', script: 'run-twitter.sh', logPrefix: 'run-twitter-', plist: 'com.m13v.social-twitter.plist' },
26
26
  { label: 'com.m13v.social-linkedin', name: 'LinkedIn', type: 'Post', platform: 'LinkedIn', script: 'run-linkedin.sh', logPrefix: 'run-linkedin-', plist: 'com.m13v.social-linkedin.plist' },
27
27
  { label: 'com.m13v.social-moltbook', name: 'MoltBook', type: 'Post', platform: 'MoltBook', script: 'run-moltbook.sh', logPrefix: 'run-moltbook-', plist: 'com.m13v.social-moltbook.plist' },
28
- { label: 'com.m13v.social-github', name: 'GitHub', type: 'Post', platform: 'GitHub', script: 'github.sh', logPrefix: 'github-', plist: 'com.m13v.social-github.plist' },
28
+ { label: 'com.m13v.social-github', name: 'GitHub', type: 'Post', platform: 'GitHub', script: 'run-github.sh', logPrefix: 'run-github-', plist: 'com.m13v.social-github.plist' },
29
29
  // Engage row
30
30
  { label: 'com.m13v.social-engage', name: 'Engage Reddit+MB', type: 'Engage', platform: 'Reddit', script: 'engage.sh', logPrefix: 'engage-', plist: 'com.m13v.social-engage.plist' },
31
31
  { label: 'com.m13v.social-engage-twitter', name: 'Engage Twitter', type: 'Engage', platform: 'Twitter', script: 'engage-twitter.sh', logPrefix: 'engage-twitter-', plist: 'com.m13v.social-engage-twitter.plist' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js"
@@ -12,10 +12,13 @@
12
12
  "config.example.json",
13
13
  ".env.example",
14
14
  "SKILL.md",
15
- "skill/run.sh",
15
+ "skill/run-reddit.sh",
16
+ "skill/run-twitter.sh",
17
+ "skill/run-linkedin.sh",
18
+ "skill/run-moltbook.sh",
19
+ "skill/run-github.sh",
16
20
  "skill/stats.sh",
17
21
  "skill/engage.sh",
18
- "skill/github.sh",
19
22
  "setup/SKILL.md"
20
23
  ],
21
24
  "keywords": [
@@ -35,6 +35,8 @@ CREATE TABLE IF NOT EXISTS posts (
35
35
  ALTER TABLE posts ADD COLUMN IF NOT EXISTS link_edited_at TIMESTAMP;
36
36
  ALTER TABLE posts ADD COLUMN IF NOT EXISTS link_edit_content TEXT;
37
37
  ALTER TABLE posts ADD COLUMN IF NOT EXISTS scan_no_change_count INTEGER DEFAULT 0;
38
+ ALTER TABLE posts ADD COLUMN IF NOT EXISTS project_name TEXT;
39
+ ALTER TABLE posts ADD COLUMN IF NOT EXISTS feedback_report_used BOOLEAN DEFAULT FALSE;
38
40
 
39
41
  CREATE INDEX IF NOT EXISTS idx_posts_platform ON posts(platform);
40
42
 
@@ -76,15 +76,16 @@ def get_recent_posts(limit=5):
76
76
  return [row[0] for row in rows]
77
77
 
78
78
 
79
- def check_rate_limit(max_per_day=None):
80
- """Return (posts_today, can_post). No limit by default."""
79
+ def check_rate_limit(max_per_day=4000):
80
+ """Return (posts_today, can_post). Default limit: 4000/day."""
81
81
  conn = dbmod.get_conn()
82
82
  row = conn.execute(
83
83
  "SELECT COUNT(*) FROM posts WHERE posted_at >= NOW() - INTERVAL '24 hours' AND platform != 'github_issues'"
84
84
  ).fetchone()
85
85
  conn.close()
86
86
  count = row[0]
87
- return count, True
87
+ can_post = count < max_per_day if max_per_day else True
88
+ return count, can_post
88
89
 
89
90
 
90
91
  def fetch_reddit_threads(subreddits, sort="new", limit=10, user_agent="social-autoposter/1.0"):
@@ -354,11 +355,27 @@ def main():
354
355
  parser.add_argument("--include-twitter", action="store_true", help="Generate X/Twitter search URLs")
355
356
  parser.add_argument("--include-linkedin", action="store_true", help="Generate LinkedIn search URLs")
356
357
  parser.add_argument("--include-github", action="store_true", help="Search GitHub issues via gh CLI")
358
+ parser.add_argument("--project", default=None, help="Use topics/subreddits from a specific project in config.json")
357
359
  parser.add_argument("--force", action="store_true", help="Skip rate limit check")
358
360
  args = parser.parse_args()
359
361
 
360
362
  config = load_config()
361
- subreddits = args.subreddits.split(",") if args.subreddits else config.get("subreddits", [])
363
+
364
+ # If --project is specified, use that project's config for topics/subreddits
365
+ project_config = None
366
+ if args.project:
367
+ for p in config.get("projects", []):
368
+ if p["name"].lower() == args.project.lower():
369
+ project_config = p
370
+ break
371
+ if not project_config:
372
+ print(json.dumps({"error": f"project '{args.project}' not found", "threads": []}))
373
+ sys.exit(1)
374
+
375
+ subreddits = args.subreddits.split(",") if args.subreddits else (
376
+ project_config.get("subreddits", config.get("subreddits", []))
377
+ if project_config else config.get("subreddits", [])
378
+ )
362
379
  reddit_username = config.get("accounts", {}).get("reddit", {}).get("username", "")
363
380
  user_agent = f"social-autoposter/1.0 (u/{reddit_username})" if reddit_username else "social-autoposter/1.0"
364
381
 
@@ -384,21 +401,32 @@ def main():
384
401
  threads.extend(fetch_moltbook_threads(moltbook_key))
385
402
 
386
403
  if args.include_twitter:
387
- twitter_topics = config.get("twitter_topics", [])
404
+ # Use project-specific topics if available, fall back to global
405
+ twitter_topics = (
406
+ project_config.get("twitter_topics", config.get("twitter_topics", []))
407
+ if project_config else config.get("twitter_topics", [])
408
+ )
388
409
  if args.topic:
389
410
  twitter_topics = [t for t in twitter_topics if args.topic.lower() in t.lower()]
390
411
  raw_excl = config.get("exclusions", {})
391
412
  threads.extend(generate_twitter_search_urls(twitter_topics, exclusions=raw_excl))
392
413
 
393
414
  if args.include_linkedin:
394
- linkedin_topics = config.get("linkedin_topics", [])
415
+ linkedin_topics = (
416
+ project_config.get("linkedin_topics", config.get("linkedin_topics", []))
417
+ if project_config else config.get("linkedin_topics", [])
418
+ )
395
419
  if args.topic:
396
420
  linkedin_topics = [t for t in linkedin_topics if args.topic.lower() in t.lower()]
397
421
  raw_excl = config.get("exclusions", {})
398
422
  threads.extend(generate_linkedin_search_urls(linkedin_topics, exclusions=raw_excl))
399
423
 
400
424
  if args.include_github:
401
- github_topics = config.get("accounts", {}).get("github", {}).get("search_topics", [])
425
+ github_topics = (
426
+ project_config.get("github_search_topics",
427
+ config.get("accounts", {}).get("github", {}).get("search_topics", []))
428
+ if project_config else config.get("accounts", {}).get("github", {}).get("search_topics", [])
429
+ )
402
430
  if args.topic:
403
431
  github_topics = [t for t in github_topics if args.topic.lower() in t.lower()]
404
432
  raw_excl = config.get("exclusions", {})
@@ -410,6 +438,7 @@ def main():
410
438
  output = {
411
439
  "posts_today": posts_today,
412
440
  "can_post": can_post,
441
+ "project": project_config["name"] if project_config else None,
413
442
  "total_found": len(threads),
414
443
  "candidates": len(candidates),
415
444
  "recent_post_snippets": [p[:100] if p else "" for p in recent_posts],
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ """Pick the next project to post about based on weight distribution.
3
+
4
+ Compares each project's target weight against actual posts today,
5
+ and picks the most underrepresented project.
6
+
7
+ Usage:
8
+ python3 scripts/pick_project.py # pick for any platform
9
+ python3 scripts/pick_project.py --platform reddit # pick for specific platform
10
+ python3 scripts/pick_project.py --json # output full project config as JSON
11
+ """
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import random
17
+ import sys
18
+
19
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
20
+ import db as dbmod
21
+
22
+ CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
23
+
24
+
25
+ def load_config():
26
+ with open(CONFIG_PATH) as f:
27
+ return json.load(f)
28
+
29
+
30
+ def get_posts_today_by_project(platform=None):
31
+ """Return dict of project_name -> post count for today."""
32
+ conn = dbmod.get_conn()
33
+ if platform:
34
+ rows = conn.execute(
35
+ "SELECT COALESCE(project_name, '(none)'), COUNT(*) "
36
+ "FROM posts WHERE DATE(posted_at) = CURRENT_DATE AND platform = %s "
37
+ "GROUP BY project_name",
38
+ [platform],
39
+ ).fetchall()
40
+ else:
41
+ rows = conn.execute(
42
+ "SELECT COALESCE(project_name, '(none)'), COUNT(*) "
43
+ "FROM posts WHERE DATE(posted_at) = CURRENT_DATE "
44
+ "GROUP BY project_name"
45
+ ).fetchall()
46
+ conn.close()
47
+ return {row[0]: row[1] for row in rows}
48
+
49
+
50
+ def pick_project(config, platform=None):
51
+ """Pick the most underrepresented project based on weights.
52
+
53
+ Returns the project dict from config.json.
54
+ """
55
+ projects = config.get("projects", [])
56
+ weighted = [p for p in projects if p.get("weight", 0) > 0]
57
+
58
+ # Filter by platform compatibility — skip projects that have no topics for this platform
59
+ platform_topic_key = {
60
+ "twitter": "twitter_topics",
61
+ "linkedin": "linkedin_topics",
62
+ "github_issues": "github_search_topics",
63
+ }.get(platform)
64
+ if platform_topic_key:
65
+ weighted = [p for p in weighted if p.get(platform_topic_key)]
66
+
67
+ if not weighted:
68
+ return random.choice(projects)
69
+
70
+ total_weight = sum(p["weight"] for p in weighted)
71
+ counts = get_posts_today_by_project(platform)
72
+ total_posts = sum(counts.values()) or 1 # avoid division by zero
73
+
74
+ # Calculate deficit: target_share - actual_share
75
+ # Higher deficit = more underrepresented = higher priority
76
+ scored = []
77
+ for p in weighted:
78
+ target_share = p["weight"] / total_weight
79
+ actual_count = counts.get(p["name"], 0)
80
+ actual_share = actual_count / total_posts if total_posts > 0 else 0
81
+ deficit = target_share - actual_share
82
+ scored.append((deficit, p))
83
+
84
+ # Sort by deficit descending (most underrepresented first)
85
+ scored.sort(key=lambda x: x[0], reverse=True)
86
+
87
+ # Pick from top candidates with some randomness to avoid always picking the same one
88
+ # Take all projects with deficit >= top deficit - 0.05 (within 5% of most underrepresented)
89
+ top_deficit = scored[0][0]
90
+ candidates = [p for deficit, p in scored if deficit >= top_deficit - 0.05]
91
+
92
+ return random.choice(candidates)
93
+
94
+
95
+ def main():
96
+ parser = argparse.ArgumentParser(description="Pick next project to post about")
97
+ parser.add_argument("--platform", default=None, help="Platform to check distribution for")
98
+ parser.add_argument("--json", action="store_true", help="Output full project config as JSON")
99
+ parser.add_argument("--show-weights", action="store_true", help="Show all projects and their current distribution")
100
+ args = parser.parse_args()
101
+
102
+ config = load_config()
103
+
104
+ if args.show_weights:
105
+ projects = config.get("projects", [])
106
+ weighted = [p for p in projects if p.get("weight", 0) > 0]
107
+ total_weight = sum(p.get("weight", 0) for p in weighted)
108
+ counts = get_posts_today_by_project(args.platform)
109
+ total_posts = sum(counts.values()) or 1
110
+
111
+ print(f"{'Project':25} {'Weight':>8} {'Target%':>8} {'Today':>6} {'Actual%':>8} {'Deficit':>8}")
112
+ print("-" * 73)
113
+ for p in sorted(weighted, key=lambda x: x["weight"], reverse=True):
114
+ target_pct = (p["weight"] / total_weight * 100) if total_weight else 0
115
+ actual = counts.get(p["name"], 0)
116
+ actual_pct = (actual / total_posts * 100) if total_posts > 0 else 0
117
+ deficit = target_pct - actual_pct
118
+ print(f"{p['name']:25} {p['weight']:>8} {target_pct:>7.1f}% {actual:>6} {actual_pct:>7.1f}% {deficit:>+7.1f}%")
119
+ return
120
+
121
+ project = pick_project(config, args.platform)
122
+
123
+ if args.json:
124
+ print(json.dumps(project, indent=2))
125
+ else:
126
+ print(project["name"])
127
+
128
+
129
+ if __name__ == "__main__":
130
+ main()