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 +22 -19
- package/bin/cli.js +0 -9
- package/bin/server.js +1 -1
- package/package.json +6 -3
- package/schema-postgres.sql +2 -0
- package/scripts/find_threads.py +36 -7
- package/scripts/pick_project.py +130 -0
- package/scripts/project_stats.py +322 -0
- package/scripts/recover_linkedin_urls.py +301 -0
- package/scripts/scrape_linkedin_stats.py +46 -17
- package/scripts/top_performers.py +291 -0
- package/scripts/update_stats.py +15 -3
- package/setup/SKILL.md +3 -0
- package/skill/engage.sh +25 -5
- package/skill/{github.sh → run-github.sh} +18 -6
- package/skill/run-linkedin.sh +83 -0
- package/skill/run-moltbook.sh +71 -0
- package/skill/run-reddit.sh +56 -0
- package/skill/run-twitter.sh +57 -0
- package/skill/stats.sh +97 -62
- package/skill/run.sh +0 -41
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").
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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.
|
|
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
|
|
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. **
|
|
255
|
-
12. **
|
|
256
|
-
13. **
|
|
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.
|
|
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": [
|
package/schema-postgres.sql
CHANGED
|
@@ -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
|
|
package/scripts/find_threads.py
CHANGED
|
@@ -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=
|
|
80
|
-
"""Return (posts_today, can_post).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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()
|