social-autoposter 1.0.8 → 1.0.9
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/bin/cli.js +14 -29
- package/config.example.json +0 -2
- package/package.json +2 -2
- package/{schema.sql → schema-postgres.sql} +36 -35
- package/scripts/db.py +89 -0
- package/scripts/find_threads.py +19 -20
- package/scripts/scan_replies.py +12 -35
- package/scripts/update_stats.py +12 -12
- package/setup/SKILL.md +21 -12
- package/skill/SKILL.md +7 -7
package/bin/cli.js
CHANGED
|
@@ -12,7 +12,7 @@ const PKG_ROOT = path.join(__dirname, '..');
|
|
|
12
12
|
// Files/dirs to copy from npm package to ~/social-autoposter
|
|
13
13
|
const COPY_TARGETS = [
|
|
14
14
|
'scripts',
|
|
15
|
-
'schema.sql',
|
|
15
|
+
'schema-postgres.sql',
|
|
16
16
|
'config.example.json',
|
|
17
17
|
'.env.example',
|
|
18
18
|
'SKILL.md',
|
|
@@ -23,7 +23,7 @@ const COPY_TARGETS = [
|
|
|
23
23
|
];
|
|
24
24
|
|
|
25
25
|
// Never overwrite these user files during update
|
|
26
|
-
const USER_FILES = new Set(['config.json', '
|
|
26
|
+
const USER_FILES = new Set(['config.json', '.env']);
|
|
27
27
|
|
|
28
28
|
function copyDir(src, dest) {
|
|
29
29
|
fs.mkdirSync(dest, { recursive: true });
|
|
@@ -79,22 +79,19 @@ function init() {
|
|
|
79
79
|
console.log(' .env exists — skipping');
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
//
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (result.status === 0) {
|
|
91
|
-
console.log(' created social_posts.db');
|
|
82
|
+
// Check psycopg2-binary (required to connect to Neon DB)
|
|
83
|
+
const pip3Check = spawnSync('pip3', ['show', 'psycopg2-binary'], { stdio: 'pipe' });
|
|
84
|
+
if (pip3Check.status !== 0) {
|
|
85
|
+
console.log(' installing psycopg2-binary (required for Neon DB)...');
|
|
86
|
+
const pipInstall = spawnSync('pip3', ['install', 'psycopg2-binary', '-q'], { stdio: 'inherit' });
|
|
87
|
+
if (pipInstall.status !== 0) {
|
|
88
|
+
console.warn(' WARNING: psycopg2-binary install failed — run manually:');
|
|
89
|
+
console.warn(' pip3 install psycopg2-binary');
|
|
92
90
|
} else {
|
|
93
|
-
console.
|
|
94
|
-
console.warn(' sqlite3 ~/social-autoposter/social_posts.db < ~/social-autoposter/schema.sql');
|
|
91
|
+
console.log(' psycopg2-binary installed');
|
|
95
92
|
}
|
|
96
93
|
} else {
|
|
97
|
-
console.log('
|
|
94
|
+
console.log(' psycopg2-binary already installed');
|
|
98
95
|
}
|
|
99
96
|
|
|
100
97
|
// Skill symlinks
|
|
@@ -105,19 +102,12 @@ function init() {
|
|
|
105
102
|
linkOrRelink(path.join(DEST, 'setup'), path.join(skillsDir, 'social-autoposter-setup'));
|
|
106
103
|
console.log(' ~/.claude/skills/social-autoposter-setup ->', path.join(DEST, 'setup'));
|
|
107
104
|
|
|
108
|
-
// DB symlink: ~/.claude/social_posts.db -> ~/social-autoposter/social_posts.db
|
|
109
|
-
const claudeDir = path.join(os.homedir(), '.claude');
|
|
110
|
-
try {
|
|
111
|
-
linkOrRelink(dbPath, path.join(claudeDir, 'social_posts.db'));
|
|
112
|
-
console.log(' ~/.claude/social_posts.db ->', dbPath);
|
|
113
|
-
} catch {}
|
|
114
|
-
|
|
115
105
|
console.log('');
|
|
116
106
|
console.log('Done! Next steps:');
|
|
117
107
|
console.log(' 1. Edit ~/social-autoposter/config.json with your accounts');
|
|
118
108
|
console.log(' 2. Tell your Claude agent: "set up social autoposter"');
|
|
119
109
|
console.log(' (uses the setup/SKILL.md wizard for browser login verification)');
|
|
120
|
-
console.log(' 3.
|
|
110
|
+
console.log(' 3. Posts are logged to the shared Neon DB (DATABASE_URL in .env)');
|
|
121
111
|
}
|
|
122
112
|
|
|
123
113
|
function update() {
|
|
@@ -145,9 +135,8 @@ function update() {
|
|
|
145
135
|
console.log(' updated', f);
|
|
146
136
|
}
|
|
147
137
|
|
|
148
|
-
// Re-symlink skill
|
|
138
|
+
// Re-symlink skill and setup skill in case they broke
|
|
149
139
|
const skillsDir = path.join(os.homedir(), '.claude', 'skills');
|
|
150
|
-
const claudeDir = path.join(os.homedir(), '.claude');
|
|
151
140
|
try {
|
|
152
141
|
linkOrRelink(path.join(DEST, 'skill'), path.join(skillsDir, 'social-autoposter'));
|
|
153
142
|
console.log(' re-linked ~/.claude/skills/social-autoposter');
|
|
@@ -156,10 +145,6 @@ function update() {
|
|
|
156
145
|
linkOrRelink(path.join(DEST, 'setup'), path.join(skillsDir, 'social-autoposter-setup'));
|
|
157
146
|
console.log(' re-linked ~/.claude/skills/social-autoposter-setup');
|
|
158
147
|
} catch {}
|
|
159
|
-
try {
|
|
160
|
-
linkOrRelink(path.join(DEST, 'social_posts.db'), path.join(claudeDir, 'social_posts.db'));
|
|
161
|
-
console.log(' re-linked ~/.claude/social_posts.db');
|
|
162
|
-
} catch {}
|
|
163
148
|
|
|
164
149
|
console.log('');
|
|
165
150
|
console.log('Update complete. config.json and social_posts.db were preserved.');
|
package/config.example.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "social-autoposter",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
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"
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"files": [
|
|
9
9
|
"bin/",
|
|
10
10
|
"scripts/*.py",
|
|
11
|
-
"schema.sql",
|
|
11
|
+
"schema-postgres.sql",
|
|
12
12
|
"config.example.json",
|
|
13
13
|
".env.example",
|
|
14
14
|
"SKILL.md",
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
-- schema-postgres.sql — Neon Postgres schema mirroring SQLite for SyncField
|
|
2
|
+
-- Run once: psql "$DATABASE_URL" -f schema-postgres.sql
|
|
3
|
+
|
|
1
4
|
CREATE TABLE IF NOT EXISTS posts (
|
|
2
|
-
id INTEGER PRIMARY KEY
|
|
3
|
-
platform TEXT NOT NULL
|
|
5
|
+
id INTEGER PRIMARY KEY,
|
|
6
|
+
platform TEXT NOT NULL,
|
|
4
7
|
thread_url TEXT NOT NULL,
|
|
5
8
|
thread_author TEXT,
|
|
6
9
|
thread_author_handle TEXT,
|
|
@@ -12,7 +15,7 @@ CREATE TABLE IF NOT EXISTS posts (
|
|
|
12
15
|
our_account TEXT NOT NULL,
|
|
13
16
|
posted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
14
17
|
discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
15
|
-
status TEXT DEFAULT 'active'
|
|
18
|
+
status TEXT DEFAULT 'active',
|
|
16
19
|
status_checked_at TIMESTAMP,
|
|
17
20
|
engagement_updated_at TIMESTAMP,
|
|
18
21
|
upvotes INTEGER,
|
|
@@ -28,40 +31,20 @@ CREATE TABLE IF NOT EXISTS posts (
|
|
|
28
31
|
|
|
29
32
|
CREATE INDEX IF NOT EXISTS idx_posts_platform ON posts(platform);
|
|
30
33
|
|
|
31
|
-
CREATE TABLE IF NOT EXISTS
|
|
32
|
-
id INTEGER PRIMARY KEY
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
34
|
+
CREATE TABLE IF NOT EXISTS campaigns (
|
|
35
|
+
id INTEGER PRIMARY KEY,
|
|
36
|
+
name TEXT NOT NULL,
|
|
37
|
+
prompt TEXT NOT NULL,
|
|
38
|
+
platforms TEXT DEFAULT 'x,reddit,moltbook',
|
|
39
|
+
status TEXT DEFAULT 'active',
|
|
40
|
+
max_posts_per_day INTEGER DEFAULT 4,
|
|
41
|
+
posts_made INTEGER DEFAULT 0,
|
|
42
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
43
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
61
44
|
);
|
|
62
45
|
|
|
63
46
|
CREATE TABLE IF NOT EXISTS replies (
|
|
64
|
-
id INTEGER PRIMARY KEY
|
|
47
|
+
id INTEGER PRIMARY KEY,
|
|
65
48
|
post_id INTEGER REFERENCES posts(id),
|
|
66
49
|
platform TEXT NOT NULL,
|
|
67
50
|
their_comment_id TEXT NOT NULL,
|
|
@@ -75,8 +58,26 @@ CREATE TABLE IF NOT EXISTS replies (
|
|
|
75
58
|
moltbook_post_uuid TEXT,
|
|
76
59
|
moltbook_parent_comment_uuid TEXT,
|
|
77
60
|
depth INTEGER DEFAULT 1,
|
|
78
|
-
status TEXT DEFAULT 'pending'
|
|
61
|
+
status TEXT DEFAULT 'pending',
|
|
79
62
|
skip_reason TEXT,
|
|
80
63
|
discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
81
64
|
replied_at TIMESTAMP
|
|
82
65
|
);
|
|
66
|
+
|
|
67
|
+
CREATE TABLE IF NOT EXISTS thread_comments (
|
|
68
|
+
id INTEGER PRIMARY KEY,
|
|
69
|
+
thread_id INTEGER,
|
|
70
|
+
author TEXT,
|
|
71
|
+
author_handle TEXT,
|
|
72
|
+
content TEXT,
|
|
73
|
+
engagement TEXT,
|
|
74
|
+
discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
CREATE TABLE IF NOT EXISTS _syncfield_meta (
|
|
78
|
+
key TEXT PRIMARY KEY,
|
|
79
|
+
value TEXT
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
INSERT INTO _syncfield_meta (key, value) VALUES ('last_sync', '1970-01-01T00:00:00Z')
|
|
83
|
+
ON CONFLICT (key) DO NOTHING;
|
package/scripts/db.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared Neon Postgres connection for social-autoposter.
|
|
3
|
+
|
|
4
|
+
Provides a thin psycopg2 wrapper with a sqlite3-compatible API so all
|
|
5
|
+
scripts can use the same SQL without changes to query logic.
|
|
6
|
+
|
|
7
|
+
DATABASE_URL is read from ~/social-autoposter/.env (pre-filled on install).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
ENV_PATH = os.path.expanduser("~/social-autoposter/.env")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_env():
|
|
18
|
+
if os.path.exists(ENV_PATH):
|
|
19
|
+
with open(ENV_PATH) as f:
|
|
20
|
+
for line in f:
|
|
21
|
+
line = line.strip()
|
|
22
|
+
if line and not line.startswith('#') and '=' in line:
|
|
23
|
+
k, v = line.split('=', 1)
|
|
24
|
+
os.environ.setdefault(k.strip(), v.strip())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _translate_sql(sql):
|
|
28
|
+
"""Translate SQLite-specific SQL syntax to PostgreSQL."""
|
|
29
|
+
# ? placeholders -> %s
|
|
30
|
+
sql = sql.replace('?', '%s')
|
|
31
|
+
# datetime('now', '-N hours') -> NOW() - INTERVAL 'N hours'
|
|
32
|
+
sql = re.sub(r"datetime\('now',\s*'-(\d+) hours'\)", r"NOW() - INTERVAL '\1 hours'", sql)
|
|
33
|
+
# datetime('now', '-N days') -> NOW() - INTERVAL 'N days'
|
|
34
|
+
sql = re.sub(r"datetime\('now',\s*'-(\d+) days'\)", r"NOW() - INTERVAL '\1 days'", sql)
|
|
35
|
+
# datetime('now') -> NOW()
|
|
36
|
+
sql = re.sub(r"datetime\('now'\)", 'NOW()', sql)
|
|
37
|
+
# status_checked_at=datetime('now') already handled above
|
|
38
|
+
return sql
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PGConn:
|
|
42
|
+
"""Thin psycopg2 wrapper with a sqlite3-compatible execute/commit/close API."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, conn):
|
|
45
|
+
import psycopg2.extras
|
|
46
|
+
self._conn = conn
|
|
47
|
+
self._cursor_factory = psycopg2.extras.DictCursor
|
|
48
|
+
|
|
49
|
+
def execute(self, sql, params=None):
|
|
50
|
+
cur = self._conn.cursor(cursor_factory=self._cursor_factory)
|
|
51
|
+
sql = _translate_sql(sql)
|
|
52
|
+
if params is not None:
|
|
53
|
+
cur.execute(sql, list(params))
|
|
54
|
+
else:
|
|
55
|
+
cur.execute(sql)
|
|
56
|
+
return cur
|
|
57
|
+
|
|
58
|
+
def commit(self):
|
|
59
|
+
self._conn.commit()
|
|
60
|
+
|
|
61
|
+
def close(self):
|
|
62
|
+
self._conn.close()
|
|
63
|
+
|
|
64
|
+
# No-op to absorb sqlite3.Row assignments
|
|
65
|
+
@property
|
|
66
|
+
def row_factory(self):
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
@row_factory.setter
|
|
70
|
+
def row_factory(self, val):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_conn():
|
|
75
|
+
"""Return a PGConn connected to the central Neon database."""
|
|
76
|
+
load_env()
|
|
77
|
+
url = os.environ.get('DATABASE_URL')
|
|
78
|
+
if not url:
|
|
79
|
+
print("ERROR: DATABASE_URL not set in ~/social-autoposter/.env", file=sys.stderr)
|
|
80
|
+
print(" Re-run: npx social-autoposter init", file=sys.stderr)
|
|
81
|
+
sys.exit(1)
|
|
82
|
+
try:
|
|
83
|
+
import psycopg2
|
|
84
|
+
except ImportError:
|
|
85
|
+
print("ERROR: psycopg2-binary not installed.", file=sys.stderr)
|
|
86
|
+
print(" Run: pip3 install psycopg2-binary", file=sys.stderr)
|
|
87
|
+
sys.exit(1)
|
|
88
|
+
conn = psycopg2.connect(url)
|
|
89
|
+
return PGConn(conn)
|
package/scripts/find_threads.py
CHANGED
|
@@ -13,13 +13,14 @@ import argparse
|
|
|
13
13
|
import json
|
|
14
14
|
import os
|
|
15
15
|
import re
|
|
16
|
-
import sqlite3
|
|
17
16
|
import sys
|
|
18
17
|
import time
|
|
19
18
|
import urllib.request
|
|
20
19
|
from datetime import datetime, timezone
|
|
21
20
|
|
|
22
|
-
|
|
21
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
22
|
+
import db as dbmod
|
|
23
|
+
|
|
23
24
|
CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
|
|
24
25
|
|
|
25
26
|
|
|
@@ -43,29 +44,29 @@ def fetch_json(url, headers=None, user_agent="social-autoposter/1.0"):
|
|
|
43
44
|
return None
|
|
44
45
|
|
|
45
46
|
|
|
46
|
-
def get_already_posted(
|
|
47
|
+
def get_already_posted():
|
|
47
48
|
"""Return set of thread URLs we've already posted in."""
|
|
48
|
-
|
|
49
|
-
rows =
|
|
50
|
-
|
|
49
|
+
conn = dbmod.get_conn()
|
|
50
|
+
rows = conn.execute("SELECT thread_url FROM posts WHERE thread_url IS NOT NULL").fetchall()
|
|
51
|
+
conn.close()
|
|
51
52
|
return {row[0] for row in rows}
|
|
52
53
|
|
|
53
54
|
|
|
54
|
-
def get_recent_posts(
|
|
55
|
+
def get_recent_posts(limit=5):
|
|
55
56
|
"""Return our last N post contents for repetition checking."""
|
|
56
|
-
|
|
57
|
-
rows =
|
|
58
|
-
|
|
57
|
+
conn = dbmod.get_conn()
|
|
58
|
+
rows = conn.execute("SELECT our_content FROM posts ORDER BY id DESC LIMIT %s", [limit]).fetchall()
|
|
59
|
+
conn.close()
|
|
59
60
|
return [row[0] for row in rows]
|
|
60
61
|
|
|
61
62
|
|
|
62
|
-
def check_rate_limit(
|
|
63
|
+
def check_rate_limit(max_per_day=40):
|
|
63
64
|
"""Return (posts_today, can_post)."""
|
|
64
|
-
|
|
65
|
-
row =
|
|
66
|
-
"SELECT COUNT(*) FROM posts WHERE posted_at >=
|
|
65
|
+
conn = dbmod.get_conn()
|
|
66
|
+
row = conn.execute(
|
|
67
|
+
"SELECT COUNT(*) FROM posts WHERE posted_at >= NOW() - INTERVAL '24 hours'"
|
|
67
68
|
).fetchone()
|
|
68
|
-
|
|
69
|
+
conn.close()
|
|
69
70
|
count = row[0]
|
|
70
71
|
return count, count < max_per_day
|
|
71
72
|
|
|
@@ -145,7 +146,6 @@ def filter_threads(threads, already_posted, topic=None):
|
|
|
145
146
|
|
|
146
147
|
def main():
|
|
147
148
|
parser = argparse.ArgumentParser(description="Find candidate threads to comment on")
|
|
148
|
-
parser.add_argument("--db", default=None, help="Path to SQLite database")
|
|
149
149
|
parser.add_argument("--subreddits", default=None, help="Comma-separated subreddits (e.g. ClaudeAI,programming)")
|
|
150
150
|
parser.add_argument("--topic", default=None, help="Filter threads by topic keyword")
|
|
151
151
|
parser.add_argument("--sort", default="new", choices=["new", "hot", "top"], help="Reddit sort order")
|
|
@@ -154,19 +154,18 @@ def main():
|
|
|
154
154
|
args = parser.parse_args()
|
|
155
155
|
|
|
156
156
|
config = load_config()
|
|
157
|
-
db_path = args.db or os.path.expanduser(config.get("database", DEFAULT_DB))
|
|
158
157
|
subreddits = args.subreddits.split(",") if args.subreddits else config.get("subreddits", [])
|
|
159
158
|
reddit_username = config.get("accounts", {}).get("reddit", {}).get("username", "")
|
|
160
159
|
user_agent = f"social-autoposter/1.0 (u/{reddit_username})" if reddit_username else "social-autoposter/1.0"
|
|
161
160
|
|
|
162
161
|
# Rate limit check
|
|
163
|
-
posts_today, can_post = check_rate_limit(
|
|
162
|
+
posts_today, can_post = check_rate_limit()
|
|
164
163
|
if not can_post:
|
|
165
164
|
print(json.dumps({"error": "rate_limit", "posts_today": posts_today, "threads": []}))
|
|
166
165
|
sys.exit(1)
|
|
167
166
|
|
|
168
|
-
already_posted = get_already_posted(
|
|
169
|
-
recent_posts = get_recent_posts(
|
|
167
|
+
already_posted = get_already_posted()
|
|
168
|
+
recent_posts = get_recent_posts()
|
|
170
169
|
|
|
171
170
|
# Fetch threads
|
|
172
171
|
threads = fetch_reddit_threads(subreddits, sort=args.sort, limit=args.limit, user_agent=user_agent)
|
package/scripts/scan_replies.py
CHANGED
|
@@ -14,15 +14,16 @@ import argparse
|
|
|
14
14
|
import json
|
|
15
15
|
import os
|
|
16
16
|
import re
|
|
17
|
-
import sqlite3
|
|
18
17
|
import sys
|
|
19
18
|
import time
|
|
20
19
|
import urllib.request
|
|
21
20
|
from datetime import datetime, timedelta, timezone
|
|
22
21
|
|
|
22
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
23
|
+
import db as dbmod
|
|
24
|
+
|
|
23
25
|
STALENESS_DAYS = 30
|
|
24
26
|
MIN_WORDS = 5
|
|
25
|
-
DEFAULT_DB = os.path.expanduser("~/social-autoposter/social_posts.db")
|
|
26
27
|
CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
|
|
27
28
|
|
|
28
29
|
|
|
@@ -70,9 +71,8 @@ def fetch_json(url, headers=None, user_agent="social-autoposter/1.0", retries=5)
|
|
|
70
71
|
|
|
71
72
|
|
|
72
73
|
class ReplyScanner:
|
|
73
|
-
def __init__(self,
|
|
74
|
-
self.db =
|
|
75
|
-
self.db.row_factory = sqlite3.Row
|
|
74
|
+
def __init__(self, reddit_account, user_agent="social-autoposter/1.0"):
|
|
75
|
+
self.db = dbmod.get_conn()
|
|
76
76
|
self.reddit_account = reddit_account
|
|
77
77
|
self.user_agent = user_agent
|
|
78
78
|
self.skip_authors = {"AutoModerator", "[deleted]", reddit_account}
|
|
@@ -80,31 +80,9 @@ class ReplyScanner:
|
|
|
80
80
|
self.skipped = 0
|
|
81
81
|
self.errors = 0
|
|
82
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
83
|
def already_tracked(self, platform, comment_id):
|
|
106
84
|
row = self.db.execute(
|
|
107
|
-
"SELECT COUNT(*) FROM replies WHERE platform
|
|
85
|
+
"SELECT COUNT(*) FROM replies WHERE platform=%s AND their_comment_id=%s",
|
|
108
86
|
(platform, str(comment_id)),
|
|
109
87
|
).fetchone()
|
|
110
88
|
return row[0] > 0
|
|
@@ -120,7 +98,7 @@ class ReplyScanner:
|
|
|
120
98
|
"""INSERT INTO replies
|
|
121
99
|
(post_id, platform, their_comment_id, their_author, their_content, their_comment_url,
|
|
122
100
|
parent_reply_id, depth, status, skip_reason, moltbook_post_uuid, moltbook_parent_comment_uuid)
|
|
123
|
-
VALUES (
|
|
101
|
+
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
|
|
124
102
|
(post_id, platform, comment_id, author, content, comment_url,
|
|
125
103
|
parent_reply_id, depth, status, skip_reason, moltbook_post_uuid, moltbook_parent_comment_uuid),
|
|
126
104
|
)
|
|
@@ -218,7 +196,7 @@ class ReplyScanner:
|
|
|
218
196
|
print("Scanning Reddit posts for replies...")
|
|
219
197
|
posts = self.db.execute(
|
|
220
198
|
"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
|
|
199
|
+
"WHERE platform='reddit' AND status='active' AND our_url IS NOT NULL AND our_url != '' AND our_url LIKE 'http%%'"
|
|
222
200
|
).fetchall()
|
|
223
201
|
|
|
224
202
|
for post in posts:
|
|
@@ -264,7 +242,7 @@ class ReplyScanner:
|
|
|
264
242
|
print("\nScanning replies to our previous replies...")
|
|
265
243
|
replied_rows = self.db.execute(
|
|
266
244
|
"SELECT id, platform, our_reply_url, post_id, depth "
|
|
267
|
-
"FROM replies WHERE status='replied' AND our_reply_url IS NOT NULL"
|
|
245
|
+
"FROM replies WHERE status='replied' AND our_reply_url IS NOT NULL",
|
|
268
246
|
).fetchall()
|
|
269
247
|
|
|
270
248
|
for row in replied_rows:
|
|
@@ -299,7 +277,7 @@ class ReplyScanner:
|
|
|
299
277
|
print("\nScanning Moltbook posts for replies...")
|
|
300
278
|
posts = self.db.execute(
|
|
301
279
|
"SELECT id, our_url FROM posts "
|
|
302
|
-
"WHERE platform='moltbook' AND status='active' AND our_url IS NOT NULL"
|
|
280
|
+
"WHERE platform='moltbook' AND status='active' AND our_url IS NOT NULL",
|
|
303
281
|
).fetchall()
|
|
304
282
|
|
|
305
283
|
for post in posts:
|
|
@@ -342,20 +320,19 @@ class ReplyScanner:
|
|
|
342
320
|
|
|
343
321
|
def main():
|
|
344
322
|
parser = argparse.ArgumentParser(description="Scan for replies to our social posts")
|
|
345
|
-
parser.add_argument("--db", default=None, help="Path to SQLite database")
|
|
346
323
|
parser.add_argument("--reddit-account", default=None, help="Reddit username")
|
|
347
324
|
args = parser.parse_args()
|
|
348
325
|
|
|
349
326
|
config = load_config()
|
|
350
|
-
db_path = args.db or os.path.expanduser(config.get("database", DEFAULT_DB))
|
|
351
327
|
reddit_account = args.reddit_account or config.get("accounts", {}).get("reddit", {}).get("username", "")
|
|
352
328
|
|
|
353
329
|
if not reddit_account:
|
|
354
330
|
print("ERROR: Reddit account not configured. Set it in config.json or pass --reddit-account")
|
|
355
331
|
sys.exit(1)
|
|
356
332
|
|
|
333
|
+
dbmod.load_env()
|
|
357
334
|
user_agent = f"social-autoposter/1.0 (u/{reddit_account})"
|
|
358
|
-
scanner = ReplyScanner(
|
|
335
|
+
scanner = ReplyScanner(reddit_account, user_agent)
|
|
359
336
|
scanner.scan_reddit()
|
|
360
337
|
|
|
361
338
|
moltbook_key = os.environ.get("MOLTBOOK_API_KEY", "")
|
package/scripts/update_stats.py
CHANGED
|
@@ -12,12 +12,13 @@ import argparse
|
|
|
12
12
|
import json
|
|
13
13
|
import os
|
|
14
14
|
import re
|
|
15
|
-
import sqlite3
|
|
16
15
|
import sys
|
|
17
16
|
import time
|
|
18
17
|
import urllib.request
|
|
19
18
|
|
|
20
|
-
|
|
19
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
20
|
+
import db as dbmod
|
|
21
|
+
|
|
21
22
|
CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
|
|
22
23
|
|
|
23
24
|
|
|
@@ -80,14 +81,14 @@ def update_reddit(db, user_agent, quiet=False):
|
|
|
80
81
|
score = comment_data.get("score", 0)
|
|
81
82
|
|
|
82
83
|
if body in ("[deleted]",) or author == "[deleted]":
|
|
83
|
-
db.execute("UPDATE posts SET status='deleted', status_checked_at=
|
|
84
|
+
db.execute("UPDATE posts SET status='deleted', status_checked_at=NOW() WHERE id=%s", [post_id])
|
|
84
85
|
deleted += 1
|
|
85
86
|
if not quiet:
|
|
86
87
|
print(f"DELETED [{post_id}]")
|
|
87
88
|
continue
|
|
88
89
|
|
|
89
90
|
if body == "[removed]":
|
|
90
|
-
db.execute("UPDATE posts SET status='removed', status_checked_at=
|
|
91
|
+
db.execute("UPDATE posts SET status='removed', status_checked_at=NOW() WHERE id=%s", [post_id])
|
|
91
92
|
removed += 1
|
|
92
93
|
if not quiet:
|
|
93
94
|
print(f"REMOVED [{post_id}]")
|
|
@@ -99,8 +100,8 @@ def update_reddit(db, user_agent, quiet=False):
|
|
|
99
100
|
engagement = json.dumps({"thread_score": thread_score, "thread_comments": thread_comments})
|
|
100
101
|
|
|
101
102
|
db.execute(
|
|
102
|
-
"UPDATE posts SET upvotes
|
|
103
|
-
"engagement_updated_at=
|
|
103
|
+
"UPDATE posts SET upvotes=%s, comments_count=%s, thread_engagement=%s, "
|
|
104
|
+
"engagement_updated_at=NOW(), status_checked_at=NOW() WHERE id=%s",
|
|
104
105
|
[score, thread_comments, engagement, post_id],
|
|
105
106
|
)
|
|
106
107
|
updated += 1
|
|
@@ -142,7 +143,7 @@ def update_moltbook(db, api_key, quiet=False):
|
|
|
142
143
|
|
|
143
144
|
post_data = data.get("post", {})
|
|
144
145
|
if post_data.get("is_deleted"):
|
|
145
|
-
db.execute("UPDATE posts SET status='deleted', status_checked_at=
|
|
146
|
+
db.execute("UPDATE posts SET status='deleted', status_checked_at=NOW() WHERE id=%s", [post_id])
|
|
146
147
|
deleted += 1
|
|
147
148
|
continue
|
|
148
149
|
|
|
@@ -153,8 +154,8 @@ def update_moltbook(db, api_key, quiet=False):
|
|
|
153
154
|
engagement = json.dumps({"score": score, "upvotes": upvotes, "comment_count": comment_count})
|
|
154
155
|
|
|
155
156
|
db.execute(
|
|
156
|
-
"UPDATE posts SET upvotes
|
|
157
|
-
"engagement_updated_at=
|
|
157
|
+
"UPDATE posts SET upvotes=%s, comments_count=%s, thread_engagement=%s, "
|
|
158
|
+
"engagement_updated_at=NOW(), status_checked_at=NOW() WHERE id=%s",
|
|
158
159
|
[upvotes, comment_count, engagement, post_id],
|
|
159
160
|
)
|
|
160
161
|
updated += 1
|
|
@@ -167,17 +168,16 @@ def update_moltbook(db, api_key, quiet=False):
|
|
|
167
168
|
|
|
168
169
|
def main():
|
|
169
170
|
parser = argparse.ArgumentParser(description="Update engagement stats for social posts")
|
|
170
|
-
parser.add_argument("--db", default=None, help="Path to SQLite database")
|
|
171
171
|
parser.add_argument("--quiet", action="store_true", help="Minimal output")
|
|
172
172
|
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
173
173
|
args = parser.parse_args()
|
|
174
174
|
|
|
175
175
|
config = load_config()
|
|
176
|
-
db_path = args.db or os.path.expanduser(config.get("database", DEFAULT_DB))
|
|
177
176
|
reddit_username = config.get("accounts", {}).get("reddit", {}).get("username", "")
|
|
178
177
|
user_agent = f"social-autoposter/1.0 (u/{reddit_username})" if reddit_username else "social-autoposter/1.0"
|
|
179
178
|
|
|
180
|
-
|
|
179
|
+
dbmod.load_env()
|
|
180
|
+
db = dbmod.get_conn()
|
|
181
181
|
|
|
182
182
|
reddit_stats = update_reddit(db, user_agent, quiet=args.quiet)
|
|
183
183
|
moltbook_stats = update_moltbook(db, os.environ.get("MOLTBOOK_API_KEY", ""), quiet=args.quiet)
|
package/setup/SKILL.md
CHANGED
|
@@ -39,10 +39,10 @@ If NOT_FOUND, install:
|
|
|
39
39
|
npx social-autoposter init
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
This copies all scripts,
|
|
42
|
+
This copies all scripts, skill files, and config templates to `~/social-autoposter/`. It also:
|
|
43
43
|
- Creates `config.json` from `config.example.json` (if missing)
|
|
44
|
-
- Creates `.env` from `.env.example` (if missing) — includes pre-filled Neon DATABASE_URL
|
|
45
|
-
-
|
|
44
|
+
- Creates `.env` from `.env.example` (if missing) — includes pre-filled Neon `DATABASE_URL`
|
|
45
|
+
- Installs `psycopg2-binary` (Python driver for Neon)
|
|
46
46
|
- Symlinks `~/.claude/skills/social-autoposter` → `~/social-autoposter/skill`
|
|
47
47
|
|
|
48
48
|
To update scripts later without touching config/data:
|
|
@@ -52,18 +52,27 @@ npx social-autoposter update
|
|
|
52
52
|
|
|
53
53
|
Set `SKILL_DIR=~/social-autoposter` for the rest of this wizard.
|
|
54
54
|
|
|
55
|
-
### Step 2: Verify the database
|
|
55
|
+
### Step 2: Verify the Neon database connection
|
|
56
|
+
|
|
57
|
+
Load the env and test the connection:
|
|
56
58
|
|
|
57
59
|
```bash
|
|
58
|
-
|
|
60
|
+
source "$SKILL_DIR/.env"
|
|
61
|
+
python3 -c "
|
|
62
|
+
import psycopg2, os
|
|
63
|
+
conn = psycopg2.connect(os.environ['DATABASE_URL'])
|
|
64
|
+
cur = conn.cursor()
|
|
65
|
+
cur.execute(\"SELECT COUNT(*) FROM posts\")
|
|
66
|
+
print('Connected. Posts in DB:', cur.fetchone()[0])
|
|
67
|
+
conn.close()
|
|
68
|
+
"
|
|
59
69
|
```
|
|
60
70
|
|
|
61
|
-
Expected
|
|
71
|
+
Expected: `Connected. Posts in DB: <number>` (any number is fine, including 0).
|
|
62
72
|
|
|
63
|
-
If missing
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
```
|
|
73
|
+
If psycopg2 is missing: `pip3 install psycopg2-binary`
|
|
74
|
+
|
|
75
|
+
If the connection fails, check that `DATABASE_URL` is set in `$SKILL_DIR/.env`.
|
|
67
76
|
|
|
68
77
|
### Step 3: Configure accounts
|
|
69
78
|
|
|
@@ -232,8 +241,8 @@ Print a summary:
|
|
|
232
241
|
```
|
|
233
242
|
Social Autoposter Setup Complete
|
|
234
243
|
|
|
235
|
-
Installed: ~/social-autoposter (v1.0.
|
|
236
|
-
Database:
|
|
244
|
+
Installed: ~/social-autoposter (v1.0.8 via npm)
|
|
245
|
+
Database: Neon Postgres (DATABASE_URL in .env)
|
|
237
246
|
Config: ~/social-autoposter/config.json
|
|
238
247
|
Env: ~/social-autoposter/.env
|
|
239
248
|
Skill: ~/.claude/skills/social-autoposter
|
package/skill/SKILL.md
CHANGED
|
@@ -37,7 +37,7 @@ Key fields you'll use throughout every workflow:
|
|
|
37
37
|
- `subreddits` — list of subreddits to monitor and post in
|
|
38
38
|
- `content_angle` — the user's unique perspective for writing authentic comments
|
|
39
39
|
- `projects` — products/repos to mention naturally when relevant (each has `name`, `description`, `website`, `github`, `topics`)
|
|
40
|
-
- `database` —
|
|
40
|
+
- `database` — unused (DB is Neon Postgres via `DATABASE_URL` in `.env`)
|
|
41
41
|
|
|
42
42
|
Use these values everywhere below instead of any hardcoded names or links.
|
|
43
43
|
|
|
@@ -60,7 +60,7 @@ python3 ~/social-autoposter/scripts/update_stats.py --quiet
|
|
|
60
60
|
### 1. Rate limit check
|
|
61
61
|
|
|
62
62
|
```sql
|
|
63
|
-
SELECT COUNT(*) FROM posts WHERE posted_at >=
|
|
63
|
+
SELECT COUNT(*) FROM posts WHERE posted_at >= NOW() - INTERVAL '24 hours'
|
|
64
64
|
```
|
|
65
65
|
Max 40 posts per 24 hours. Stop if at limit.
|
|
66
66
|
|
|
@@ -123,7 +123,7 @@ Verify: fetch post by UUID, check `verification_status` is `"verified"`.
|
|
|
123
123
|
INSERT INTO posts (platform, thread_url, thread_author, thread_author_handle,
|
|
124
124
|
thread_title, thread_content, our_url, our_content, our_account,
|
|
125
125
|
source_summary, status, posted_at)
|
|
126
|
-
VALUES (
|
|
126
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'active', NOW());
|
|
127
127
|
```
|
|
128
128
|
|
|
129
129
|
Use the account value from `config.json` for `our_account`.
|
|
@@ -144,7 +144,7 @@ Max 1 original post per 24 hours. Max 3 per week.
|
|
|
144
144
|
|
|
145
145
|
```sql
|
|
146
146
|
SELECT platform, thread_title, posted_at FROM posts
|
|
147
|
-
WHERE source_summary LIKE '%' ||
|
|
147
|
+
WHERE source_summary LIKE '%' || %s || '%' AND posted_at >= NOW() - INTERVAL '30 days'
|
|
148
148
|
ORDER BY posted_at DESC;
|
|
149
149
|
```
|
|
150
150
|
|
|
@@ -180,7 +180,7 @@ Choose the single best subreddit from `config.json → subreddits` for this topi
|
|
|
180
180
|
INSERT INTO posts (platform, thread_url, thread_author, thread_author_handle,
|
|
181
181
|
thread_title, thread_content, our_url, our_content, our_account,
|
|
182
182
|
source_summary, status, posted_at)
|
|
183
|
-
VALUES (
|
|
183
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'active', NOW());
|
|
184
184
|
```
|
|
185
185
|
|
|
186
186
|
For original posts: `thread_url` = `our_url`, `thread_author` = our account from config.json.
|
|
@@ -223,8 +223,8 @@ Draft replies: 2-4 sentences, casual, expand the topic. Apply Tiered Reply Strat
|
|
|
223
223
|
|
|
224
224
|
Post via browser (Reddit/X) or API (Moltbook). Update:
|
|
225
225
|
```sql
|
|
226
|
-
UPDATE replies SET status='replied', our_reply_content
|
|
227
|
-
replied_at=
|
|
226
|
+
UPDATE replies SET status='replied', our_reply_content=%s, our_reply_url=%s,
|
|
227
|
+
replied_at=NOW() WHERE id=%s
|
|
228
228
|
```
|
|
229
229
|
|
|
230
230
|
### Phase C: X/Twitter replies (browser required)
|