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 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', 'social_posts.db', '.env']);
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
- // Create DB from schema if missing
83
- const dbPath = path.join(DEST, 'social_posts.db');
84
- if (!fs.existsSync(dbPath)) {
85
- const schemaPath = path.join(DEST, 'schema.sql');
86
- const result = spawnSync('sqlite3', [dbPath], {
87
- input: fs.readFileSync(schemaPath),
88
- stdio: ['pipe', 'inherit', 'inherit'],
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.warn(' WARNING: sqlite3 failed — create DB manually:');
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(' social_posts.db exists — skipping');
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. Or configure manually and run: bash ~/social-autoposter/setup.sh');
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, setup skill, and DB in case they broke
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.');
@@ -1,7 +1,5 @@
1
1
  {
2
- "database": "~/social-autoposter/social_posts.db",
3
2
  "prompt_db": "~/claude-prompt-db/prompts.db",
4
- "sync_script": "~/social-autoposter/syncfield.sh",
5
3
  "stats_script": "~/.claude/skills/social-autoposter/stats.sh",
6
4
 
7
5
  "accounts": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.0.8",
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 AUTOINCREMENT,
3
- platform TEXT NOT NULL CHECK(platform IN ('reddit', 'x', 'linkedin')),
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' CHECK(status IN ('active', 'inactive', 'deleted', 'removed')),
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 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
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 AUTOINCREMENT,
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' CHECK(status IN ('pending','replied','skipped','error')),
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)
@@ -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
- DEFAULT_DB = os.path.expanduser("~/social-autoposter/social_posts.db")
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(db_path):
47
+ def get_already_posted():
47
48
  """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()
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(db_path, limit=5):
55
+ def get_recent_posts(limit=5):
55
56
  """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()
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(db_path, max_per_day=40):
63
+ def check_rate_limit(max_per_day=40):
63
64
  """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')"
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
- db.close()
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(db_path)
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(db_path)
169
- recent_posts = get_recent_posts(db_path)
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)
@@ -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, 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
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=? AND their_comment_id=?",
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(db_path, reddit_account, user_agent)
335
+ scanner = ReplyScanner(reddit_account, user_agent)
359
336
  scanner.scan_reddit()
360
337
 
361
338
  moltbook_key = os.environ.get("MOLTBOOK_API_KEY", "")
@@ -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
- DEFAULT_DB = os.path.expanduser("~/social-autoposter/social_posts.db")
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=datetime('now') WHERE id=?", [post_id])
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=datetime('now') WHERE id=?", [post_id])
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=?, comments_count=?, thread_engagement=?, "
103
- "engagement_updated_at=datetime('now'), status_checked_at=datetime('now') WHERE id=?",
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=datetime('now') WHERE id=?", [post_id])
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=?, comments_count=?, thread_engagement=?, "
157
- "engagement_updated_at=datetime('now'), status_checked_at=datetime('now') WHERE id=?",
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
- db = sqlite3.connect(db_path, timeout=30)
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, schema, skill files, and config templates to `~/social-autoposter/`. It also:
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
- - Creates `social_posts.db` from `schema.sql` (if missing)
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
- sqlite3 "$SKILL_DIR/social_posts.db" "SELECT name FROM sqlite_master WHERE type='table';"
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 tables: `posts`, `threads`, `our_posts`, `thread_comments`, `replies`.
71
+ Expected: `Connected. Posts in DB: <number>` (any number is fine, including 0).
62
72
 
63
- If missing, create it:
64
- ```bash
65
- sqlite3 "$SKILL_DIR/social_posts.db" < "$SKILL_DIR/schema.sql"
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.3 via npm)
236
- Database: ~/social-autoposter/social_posts.db
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` — path to SQLite DB (default: `~/social-autoposter/social_posts.db`)
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 >= datetime('now', '-24 hours')
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', datetime('now'));
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 '%' || ? || '%' AND posted_at >= datetime('now', '-30 days')
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', datetime('now'));
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=?, our_reply_url=?,
227
- replied_at=datetime('now') WHERE id=?
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)