social-autoposter 1.0.8 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +1 -1
- package/README.md +4 -7
- package/SKILL.md +5 -9
- package/bin/cli.js +96 -32
- package/config.example.json +0 -2
- package/package.json +3 -5
- package/{schema.sql → schema-postgres.sql} +33 -17
- 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 +38 -16
- package/skill/SKILL.md +13 -7
- package/skill/engage.sh +24 -26
- package/skill/stats.sh +2 -11
- package/launchd/com.m13v.social-autoposter.plist +0 -28
- package/launchd/com.m13v.social-engage.plist +0 -28
- package/launchd/com.m13v.social-stats.plist +0 -28
- package/syncfield.sh +0 -78
package/.env.example
CHANGED
|
@@ -7,5 +7,5 @@
|
|
|
7
7
|
MOLTBOOK_API_KEY=
|
|
8
8
|
|
|
9
9
|
# Shared Neon Postgres database — pre-filled, read/write access, no delete
|
|
10
|
-
# All social-autoposter users write to this shared
|
|
10
|
+
# All social-autoposter users write to this shared Neon Postgres DB
|
|
11
11
|
DATABASE_URL=postgresql://social_autoposter_public:sap_public_2026@ep-empty-bird-ai7uh8cy-pooler.c-4.us-east-1.aws.neon.tech/neondb?sslmode=require
|
package/README.md
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as an AI agent skill or use the standalone Python scripts.
|
|
4
4
|
|
|
5
|
-
[**Browse the data in Datasette Lite**](https://lite.datasette.io/?url=https://raw.githubusercontent.com/m13v/social-autoposter/main/social_posts.db)
|
|
6
|
-
|
|
7
5
|
## Install as a skill
|
|
8
6
|
|
|
9
7
|
```bash
|
|
@@ -20,7 +18,7 @@ npx social-autoposter update
|
|
|
20
18
|
Or set up manually:
|
|
21
19
|
```bash
|
|
22
20
|
cp config.example.json config.json # edit with your accounts
|
|
23
|
-
|
|
21
|
+
psql "$DATABASE_URL" -f schema-postgres.sql # initialize the Neon DB
|
|
24
22
|
bash setup.sh # symlinks + launchd (macOS)
|
|
25
23
|
```
|
|
26
24
|
|
|
@@ -49,11 +47,12 @@ SKILL.md (the playbook)
|
|
|
49
47
|
social-autoposter/
|
|
50
48
|
├── SKILL.md <- skill playbook (generic, publishable)
|
|
51
49
|
├── config.example.json <- config template (accounts, subreddits, content angle)
|
|
52
|
-
├── schema.sql
|
|
50
|
+
├── schema-postgres.sql <- Neon Postgres DB schema
|
|
53
51
|
├── setup.sh <- creates symlinks, loads launchd agents
|
|
54
52
|
├── setup/
|
|
55
53
|
│ └── SKILL.md <- interactive setup wizard skill
|
|
56
54
|
├── scripts/
|
|
55
|
+
│ ├── db.py <- Neon Postgres connection wrapper
|
|
57
56
|
│ ├── find_threads.py <- find candidate threads via Reddit/Moltbook API
|
|
58
57
|
│ ├── scan_replies.py <- scan for new replies to our posts via API
|
|
59
58
|
│ └── update_stats.py <- fetch engagement stats via API
|
|
@@ -63,15 +62,13 @@ social-autoposter/
|
|
|
63
62
|
│ ├── stats.sh <- 6-hourly stats (launchd wrapper)
|
|
64
63
|
│ ├── engage.sh <- 2-hourly engagement (launchd wrapper)
|
|
65
64
|
│ └── logs/ <- runtime logs (gitignored)
|
|
66
|
-
├── social_posts.db <- SQLite database (committed for Datasette)
|
|
67
|
-
├── syncfield.sh <- sync SQLite -> Neon Postgres
|
|
68
65
|
└── launchd/ <- macOS LaunchAgent plists
|
|
69
66
|
```
|
|
70
67
|
|
|
71
68
|
## For other AI agents
|
|
72
69
|
|
|
73
70
|
The skill is designed to work with any agent that has:
|
|
74
|
-
- **Shell access** (to run Python scripts and
|
|
71
|
+
- **Shell access** (to run Python scripts and psql)
|
|
75
72
|
- **Browser automation** (Playwright, Selenium, etc. for posting)
|
|
76
73
|
- **An LLM** (for drafting comments in the right tone)
|
|
77
74
|
|
package/SKILL.md
CHANGED
|
@@ -26,7 +26,7 @@ Key fields:
|
|
|
26
26
|
- `subreddits` — target subreddits to monitor
|
|
27
27
|
- `content_angle` — your unique perspective for authentic comments
|
|
28
28
|
- `projects` — your products/repos to mention when relevant (with topic keywords)
|
|
29
|
-
- `database` —
|
|
29
|
+
- `database` — unused (DB is Neon Postgres via `DATABASE_URL` in `.env`)
|
|
30
30
|
|
|
31
31
|
## Helper Scripts
|
|
32
32
|
|
|
@@ -67,7 +67,7 @@ Find a thread, draft a comment, post it, log it.
|
|
|
67
67
|
### 1. Rate limit check
|
|
68
68
|
|
|
69
69
|
```sql
|
|
70
|
-
SELECT COUNT(*) FROM posts WHERE posted_at >=
|
|
70
|
+
SELECT COUNT(*) FROM posts WHERE posted_at >= NOW() - INTERVAL '24 hours'
|
|
71
71
|
```
|
|
72
72
|
If 40+ posts in the last 24 hours, stop. Max 40/day.
|
|
73
73
|
|
|
@@ -148,13 +148,9 @@ Rate limit: max 1 post per 30 minutes.
|
|
|
148
148
|
INSERT INTO posts (platform, thread_url, thread_author, thread_author_handle,
|
|
149
149
|
thread_title, thread_content, our_url, our_content, our_account,
|
|
150
150
|
source_summary, status, posted_at)
|
|
151
|
-
VALUES (
|
|
151
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'active', NOW());
|
|
152
152
|
```
|
|
153
153
|
|
|
154
|
-
### 8. Sync (if configured)
|
|
155
|
-
|
|
156
|
-
If `sync_script` is set in `config.json`, run it to push data to a remote database.
|
|
157
|
-
|
|
158
154
|
---
|
|
159
155
|
|
|
160
156
|
## Workflow: Stats (`/social-autoposter stats`)
|
|
@@ -210,8 +206,8 @@ For each pending reply:
|
|
|
210
206
|
3. Post via browser (Reddit/X) or API (Moltbook)
|
|
211
207
|
4. Update the reply record:
|
|
212
208
|
```sql
|
|
213
|
-
UPDATE replies SET status='replied', our_reply_content
|
|
214
|
-
replied_at=
|
|
209
|
+
UPDATE replies SET status='replied', our_reply_content=%s, our_reply_url=%s,
|
|
210
|
+
replied_at=NOW() WHERE id=%s
|
|
215
211
|
```
|
|
216
212
|
|
|
217
213
|
Max 5 replies per run.
|
package/bin/cli.js
CHANGED
|
@@ -8,22 +8,21 @@ const { spawnSync } = require('child_process');
|
|
|
8
8
|
|
|
9
9
|
const DEST = path.join(os.homedir(), 'social-autoposter');
|
|
10
10
|
const PKG_ROOT = path.join(__dirname, '..');
|
|
11
|
+
const HOME = os.homedir();
|
|
11
12
|
|
|
12
13
|
// Files/dirs to copy from npm package to ~/social-autoposter
|
|
13
14
|
const COPY_TARGETS = [
|
|
14
15
|
'scripts',
|
|
15
|
-
'schema.sql',
|
|
16
|
+
'schema-postgres.sql',
|
|
16
17
|
'config.example.json',
|
|
17
18
|
'.env.example',
|
|
18
19
|
'SKILL.md',
|
|
19
20
|
'skill',
|
|
20
21
|
'setup',
|
|
21
|
-
'launchd',
|
|
22
|
-
'syncfield.sh',
|
|
23
22
|
];
|
|
24
23
|
|
|
25
24
|
// Never overwrite these user files during update
|
|
26
|
-
const USER_FILES = new Set(['config.json', '
|
|
25
|
+
const USER_FILES = new Set(['config.json', '.env']);
|
|
27
26
|
|
|
28
27
|
function copyDir(src, dest) {
|
|
29
28
|
fs.mkdirSync(dest, { recursive: true });
|
|
@@ -43,6 +42,80 @@ function linkOrRelink(target, linkPath) {
|
|
|
43
42
|
fs.symlinkSync(target, linkPath);
|
|
44
43
|
}
|
|
45
44
|
|
|
45
|
+
function generatePlists() {
|
|
46
|
+
// Detect PATH for launchd (include node, homebrew, system)
|
|
47
|
+
const nodeBin = path.dirname(process.execPath);
|
|
48
|
+
const pathDirs = new Set([nodeBin, '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin']);
|
|
49
|
+
const launchdPath = [...pathDirs].join(':');
|
|
50
|
+
|
|
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
|
+
{
|
|
62
|
+
file: 'com.m13v.social-stats.plist',
|
|
63
|
+
label: 'com.m13v.social-stats',
|
|
64
|
+
script: `${DEST}/skill/stats.sh`,
|
|
65
|
+
interval: 21600,
|
|
66
|
+
runAtLoad: false,
|
|
67
|
+
stdoutLog: `${DEST}/skill/logs/launchd-stats-stdout.log`,
|
|
68
|
+
stderrLog: `${DEST}/skill/logs/launchd-stats-stderr.log`,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
file: 'com.m13v.social-engage.plist',
|
|
72
|
+
label: 'com.m13v.social-engage',
|
|
73
|
+
script: `${DEST}/skill/engage.sh`,
|
|
74
|
+
interval: 21600,
|
|
75
|
+
runAtLoad: false,
|
|
76
|
+
stdoutLog: `${DEST}/skill/logs/launchd-engage-stdout.log`,
|
|
77
|
+
stderrLog: `${DEST}/skill/logs/launchd-engage-stderr.log`,
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const launchdDir = path.join(DEST, 'launchd');
|
|
82
|
+
fs.mkdirSync(launchdDir, { recursive: true });
|
|
83
|
+
|
|
84
|
+
for (const p of plists) {
|
|
85
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
86
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
87
|
+
<plist version="1.0">
|
|
88
|
+
<dict>
|
|
89
|
+
\t<key>Label</key>
|
|
90
|
+
\t<string>${p.label}</string>
|
|
91
|
+
\t<key>ProgramArguments</key>
|
|
92
|
+
\t<array>
|
|
93
|
+
\t\t<string>/bin/bash</string>
|
|
94
|
+
\t\t<string>${p.script}</string>
|
|
95
|
+
\t</array>
|
|
96
|
+
\t<key>StartInterval</key>
|
|
97
|
+
\t<integer>${p.interval}</integer>
|
|
98
|
+
\t<key>StandardOutPath</key>
|
|
99
|
+
\t<string>${p.stdoutLog}</string>
|
|
100
|
+
\t<key>StandardErrorPath</key>
|
|
101
|
+
\t<string>${p.stderrLog}</string>
|
|
102
|
+
\t<key>EnvironmentVariables</key>
|
|
103
|
+
\t<dict>
|
|
104
|
+
\t\t<key>PATH</key>
|
|
105
|
+
\t\t<string>${launchdPath}</string>
|
|
106
|
+
\t\t<key>HOME</key>
|
|
107
|
+
\t\t<string>${HOME}</string>
|
|
108
|
+
\t</dict>
|
|
109
|
+
\t<key>RunAtLoad</key>
|
|
110
|
+
\t<${p.runAtLoad}/>
|
|
111
|
+
</dict>
|
|
112
|
+
</plist>
|
|
113
|
+
`;
|
|
114
|
+
fs.writeFileSync(path.join(launchdDir, p.file), xml);
|
|
115
|
+
}
|
|
116
|
+
console.log(' generated launchd plists with correct paths');
|
|
117
|
+
}
|
|
118
|
+
|
|
46
119
|
function init() {
|
|
47
120
|
console.log('Setting up social-autoposter in', DEST);
|
|
48
121
|
fs.mkdirSync(DEST, { recursive: true });
|
|
@@ -61,6 +134,9 @@ function init() {
|
|
|
61
134
|
console.log(' copied', f);
|
|
62
135
|
}
|
|
63
136
|
|
|
137
|
+
// Generate launchd plists with user's actual HOME
|
|
138
|
+
generatePlists();
|
|
139
|
+
|
|
64
140
|
// config.json — only if it doesn't exist
|
|
65
141
|
const configDest = path.join(DEST, 'config.json');
|
|
66
142
|
if (!fs.existsSync(configDest)) {
|
|
@@ -79,22 +155,19 @@ function init() {
|
|
|
79
155
|
console.log(' .env exists — skipping');
|
|
80
156
|
}
|
|
81
157
|
|
|
82
|
-
//
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (result.status === 0) {
|
|
91
|
-
console.log(' created social_posts.db');
|
|
158
|
+
// Check psycopg2-binary (required to connect to Neon DB)
|
|
159
|
+
const pip3Check = spawnSync('pip3', ['show', 'psycopg2-binary'], { stdio: 'pipe' });
|
|
160
|
+
if (pip3Check.status !== 0) {
|
|
161
|
+
console.log(' installing psycopg2-binary (required for Neon DB)...');
|
|
162
|
+
const pipInstall = spawnSync('pip3', ['install', 'psycopg2-binary', '-q'], { stdio: 'inherit' });
|
|
163
|
+
if (pipInstall.status !== 0) {
|
|
164
|
+
console.warn(' WARNING: psycopg2-binary install failed — run manually:');
|
|
165
|
+
console.warn(' pip3 install psycopg2-binary');
|
|
92
166
|
} else {
|
|
93
|
-
console.
|
|
94
|
-
console.warn(' sqlite3 ~/social-autoposter/social_posts.db < ~/social-autoposter/schema.sql');
|
|
167
|
+
console.log(' psycopg2-binary installed');
|
|
95
168
|
}
|
|
96
169
|
} else {
|
|
97
|
-
console.log('
|
|
170
|
+
console.log(' psycopg2-binary already installed');
|
|
98
171
|
}
|
|
99
172
|
|
|
100
173
|
// Skill symlinks
|
|
@@ -105,19 +178,12 @@ function init() {
|
|
|
105
178
|
linkOrRelink(path.join(DEST, 'setup'), path.join(skillsDir, 'social-autoposter-setup'));
|
|
106
179
|
console.log(' ~/.claude/skills/social-autoposter-setup ->', path.join(DEST, 'setup'));
|
|
107
180
|
|
|
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
181
|
console.log('');
|
|
116
182
|
console.log('Done! Next steps:');
|
|
117
183
|
console.log(' 1. Edit ~/social-autoposter/config.json with your accounts');
|
|
118
184
|
console.log(' 2. Tell your Claude agent: "set up social autoposter"');
|
|
119
185
|
console.log(' (uses the setup/SKILL.md wizard for browser login verification)');
|
|
120
|
-
console.log(' 3.
|
|
186
|
+
console.log(' 3. Posts are logged to the shared Neon DB (DATABASE_URL in .env)');
|
|
121
187
|
}
|
|
122
188
|
|
|
123
189
|
function update() {
|
|
@@ -145,9 +211,11 @@ function update() {
|
|
|
145
211
|
console.log(' updated', f);
|
|
146
212
|
}
|
|
147
213
|
|
|
148
|
-
//
|
|
214
|
+
// Regenerate launchd plists with correct paths
|
|
215
|
+
generatePlists();
|
|
216
|
+
|
|
217
|
+
// Re-symlink skill and setup skill in case they broke
|
|
149
218
|
const skillsDir = path.join(os.homedir(), '.claude', 'skills');
|
|
150
|
-
const claudeDir = path.join(os.homedir(), '.claude');
|
|
151
219
|
try {
|
|
152
220
|
linkOrRelink(path.join(DEST, 'skill'), path.join(skillsDir, 'social-autoposter'));
|
|
153
221
|
console.log(' re-linked ~/.claude/skills/social-autoposter');
|
|
@@ -156,13 +224,9 @@ function update() {
|
|
|
156
224
|
linkOrRelink(path.join(DEST, 'setup'), path.join(skillsDir, 'social-autoposter-setup'));
|
|
157
225
|
console.log(' re-linked ~/.claude/skills/social-autoposter-setup');
|
|
158
226
|
} 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
227
|
|
|
164
228
|
console.log('');
|
|
165
|
-
console.log('Update complete. config.json
|
|
229
|
+
console.log('Update complete. config.json was preserved.');
|
|
166
230
|
}
|
|
167
231
|
|
|
168
232
|
const cmd = process.argv[2];
|
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.1.0",
|
|
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",
|
|
@@ -16,9 +16,7 @@
|
|
|
16
16
|
"skill/run.sh",
|
|
17
17
|
"skill/stats.sh",
|
|
18
18
|
"skill/engage.sh",
|
|
19
|
-
"setup/SKILL.md"
|
|
20
|
-
"launchd/",
|
|
21
|
-
"syncfield.sh"
|
|
19
|
+
"setup/SKILL.md"
|
|
22
20
|
],
|
|
23
21
|
"keywords": [
|
|
24
22
|
"social-media",
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
-- schema-postgres.sql — Neon Postgres schema (primary database)
|
|
2
|
+
-- Run once: psql "$DATABASE_URL" -f schema-postgres.sql
|
|
3
|
+
|
|
1
4
|
CREATE TABLE IF NOT EXISTS posts (
|
|
2
|
-
id
|
|
3
|
-
platform TEXT NOT NULL
|
|
5
|
+
id SERIAL 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,
|
|
@@ -29,7 +32,7 @@ CREATE TABLE IF NOT EXISTS posts (
|
|
|
29
32
|
CREATE INDEX IF NOT EXISTS idx_posts_platform ON posts(platform);
|
|
30
33
|
|
|
31
34
|
CREATE TABLE IF NOT EXISTS threads (
|
|
32
|
-
id
|
|
35
|
+
id SERIAL PRIMARY KEY,
|
|
33
36
|
platform TEXT NOT NULL,
|
|
34
37
|
url TEXT NOT NULL UNIQUE,
|
|
35
38
|
author TEXT,
|
|
@@ -37,31 +40,33 @@ CREATE TABLE IF NOT EXISTS threads (
|
|
|
37
40
|
title TEXT,
|
|
38
41
|
content TEXT,
|
|
39
42
|
engagement TEXT,
|
|
40
|
-
discovered_at
|
|
43
|
+
discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
41
44
|
);
|
|
42
45
|
|
|
43
46
|
CREATE TABLE IF NOT EXISTS our_posts (
|
|
44
|
-
id
|
|
47
|
+
id SERIAL PRIMARY KEY,
|
|
45
48
|
thread_id INTEGER REFERENCES threads(id),
|
|
46
49
|
platform TEXT NOT NULL,
|
|
47
50
|
url TEXT,
|
|
48
51
|
content TEXT NOT NULL,
|
|
49
52
|
account TEXT,
|
|
50
|
-
posted_at
|
|
53
|
+
posted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
51
54
|
);
|
|
52
55
|
|
|
53
|
-
CREATE TABLE IF NOT EXISTS
|
|
54
|
-
id
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
CREATE TABLE IF NOT EXISTS campaigns (
|
|
57
|
+
id SERIAL PRIMARY KEY,
|
|
58
|
+
name TEXT NOT NULL,
|
|
59
|
+
prompt TEXT NOT NULL,
|
|
60
|
+
platforms TEXT DEFAULT 'x,reddit,moltbook',
|
|
61
|
+
status TEXT DEFAULT 'active',
|
|
62
|
+
max_posts_per_day INTEGER DEFAULT 4,
|
|
63
|
+
posts_made INTEGER DEFAULT 0,
|
|
64
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
65
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
61
66
|
);
|
|
62
67
|
|
|
63
68
|
CREATE TABLE IF NOT EXISTS replies (
|
|
64
|
-
id
|
|
69
|
+
id SERIAL PRIMARY KEY,
|
|
65
70
|
post_id INTEGER REFERENCES posts(id),
|
|
66
71
|
platform TEXT NOT NULL,
|
|
67
72
|
their_comment_id TEXT NOT NULL,
|
|
@@ -75,8 +80,19 @@ CREATE TABLE IF NOT EXISTS replies (
|
|
|
75
80
|
moltbook_post_uuid TEXT,
|
|
76
81
|
moltbook_parent_comment_uuid TEXT,
|
|
77
82
|
depth INTEGER DEFAULT 1,
|
|
78
|
-
status TEXT DEFAULT 'pending'
|
|
83
|
+
status TEXT DEFAULT 'pending',
|
|
79
84
|
skip_reason TEXT,
|
|
80
85
|
discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
81
86
|
replied_at TIMESTAMP
|
|
82
87
|
);
|
|
88
|
+
|
|
89
|
+
CREATE TABLE IF NOT EXISTS thread_comments (
|
|
90
|
+
id SERIAL PRIMARY KEY,
|
|
91
|
+
thread_id INTEGER,
|
|
92
|
+
author TEXT,
|
|
93
|
+
author_handle TEXT,
|
|
94
|
+
content TEXT,
|
|
95
|
+
engagement TEXT,
|
|
96
|
+
discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
97
|
+
);
|
|
98
|
+
|
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
|
@@ -16,8 +16,7 @@ Interactive setup wizard for social-autoposter. Walk the user through configurat
|
|
|
16
16
|
## Prerequisites
|
|
17
17
|
|
|
18
18
|
- Node.js 16+ (for `npx`)
|
|
19
|
-
- `
|
|
20
|
-
- Python 3.9+ for running helper scripts
|
|
19
|
+
- Python 3.9+ with `pip3` for running helper scripts
|
|
21
20
|
- A browser automation tool (Playwright MCP, Selenium, etc.) for platform login verification
|
|
22
21
|
|
|
23
22
|
---
|
|
@@ -31,7 +30,7 @@ Run these steps in order. Ask the user for input at each step. Don't skip ahead.
|
|
|
31
30
|
Check if already installed:
|
|
32
31
|
|
|
33
32
|
```bash
|
|
34
|
-
ls ~/social-autoposter/schema.sql 2>/dev/null && echo "FOUND" || echo "NOT_FOUND"
|
|
33
|
+
ls ~/social-autoposter/schema-postgres.sql 2>/dev/null && echo "FOUND" || echo "NOT_FOUND"
|
|
35
34
|
```
|
|
36
35
|
|
|
37
36
|
If NOT_FOUND, install:
|
|
@@ -39,10 +38,10 @@ If NOT_FOUND, install:
|
|
|
39
38
|
npx social-autoposter init
|
|
40
39
|
```
|
|
41
40
|
|
|
42
|
-
This copies all scripts,
|
|
41
|
+
This copies all scripts, skill files, and config templates to `~/social-autoposter/`. It also:
|
|
43
42
|
- Creates `config.json` from `config.example.json` (if missing)
|
|
44
|
-
- Creates `.env` from `.env.example` (if missing) — includes pre-filled Neon DATABASE_URL
|
|
45
|
-
-
|
|
43
|
+
- Creates `.env` from `.env.example` (if missing) — includes pre-filled Neon `DATABASE_URL`
|
|
44
|
+
- Installs `psycopg2-binary` (Python driver for Neon)
|
|
46
45
|
- Symlinks `~/.claude/skills/social-autoposter` → `~/social-autoposter/skill`
|
|
47
46
|
|
|
48
47
|
To update scripts later without touching config/data:
|
|
@@ -52,18 +51,27 @@ npx social-autoposter update
|
|
|
52
51
|
|
|
53
52
|
Set `SKILL_DIR=~/social-autoposter` for the rest of this wizard.
|
|
54
53
|
|
|
55
|
-
### Step 2: Verify the database
|
|
54
|
+
### Step 2: Verify the Neon database connection
|
|
55
|
+
|
|
56
|
+
Load the env and test the connection:
|
|
56
57
|
|
|
57
58
|
```bash
|
|
58
|
-
|
|
59
|
+
source "$SKILL_DIR/.env"
|
|
60
|
+
python3 -c "
|
|
61
|
+
import psycopg2, os
|
|
62
|
+
conn = psycopg2.connect(os.environ['DATABASE_URL'])
|
|
63
|
+
cur = conn.cursor()
|
|
64
|
+
cur.execute(\"SELECT COUNT(*) FROM posts\")
|
|
65
|
+
print('Connected. Posts in DB:', cur.fetchone()[0])
|
|
66
|
+
conn.close()
|
|
67
|
+
"
|
|
59
68
|
```
|
|
60
69
|
|
|
61
|
-
Expected
|
|
70
|
+
Expected: `Connected. Posts in DB: <number>` (any number is fine, including 0).
|
|
62
71
|
|
|
63
|
-
If missing
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
```
|
|
72
|
+
If psycopg2 is missing: `pip3 install psycopg2-binary`
|
|
73
|
+
|
|
74
|
+
If the connection fails, check that `DATABASE_URL` is set in `$SKILL_DIR/.env`.
|
|
67
75
|
|
|
68
76
|
### Step 3: Configure accounts
|
|
69
77
|
|
|
@@ -228,12 +236,18 @@ If no: "You can run manually anytime with `/social-autoposter`"
|
|
|
228
236
|
|
|
229
237
|
### Step 8: Summary
|
|
230
238
|
|
|
231
|
-
|
|
239
|
+
Read `config.json` accounts and compute each platform's stats URL:
|
|
240
|
+
- Twitter/X handle (strip leading `@`): `https://s4l.ai/stats/HANDLE`
|
|
241
|
+
- Reddit username: `https://s4l.ai/stats/USERNAME`
|
|
242
|
+
- LinkedIn name (URL-encoded spaces as `%20`): `https://s4l.ai/stats/NAME`
|
|
243
|
+
- Moltbook username: `https://s4l.ai/stats/MOLTBOOK_USERNAME`
|
|
244
|
+
|
|
245
|
+
Print a summary with real values substituted:
|
|
232
246
|
```
|
|
233
247
|
Social Autoposter Setup Complete
|
|
234
248
|
|
|
235
|
-
Installed: ~/social-autoposter (v1.0.
|
|
236
|
-
Database:
|
|
249
|
+
Installed: ~/social-autoposter (v1.0.9 via npm)
|
|
250
|
+
Database: Neon Postgres (DATABASE_URL in .env)
|
|
237
251
|
Config: ~/social-autoposter/config.json
|
|
238
252
|
Env: ~/social-autoposter/.env
|
|
239
253
|
Skill: ~/.claude/skills/social-autoposter
|
|
@@ -247,6 +261,14 @@ Social Autoposter Setup Complete
|
|
|
247
261
|
Rate limit: 40 posts per 24 hours
|
|
248
262
|
Automation: launchd (hourly post, 6h stats, 2h engage)
|
|
249
263
|
|
|
264
|
+
Your live stats pages:
|
|
265
|
+
X/Twitter: https://s4l.ai/stats/HANDLE
|
|
266
|
+
Reddit: https://s4l.ai/stats/USERNAME
|
|
267
|
+
LinkedIn: https://s4l.ai/stats/NAME
|
|
268
|
+
Moltbook: https://s4l.ai/stats/MOLTBOOK_USERNAME
|
|
269
|
+
|
|
250
270
|
Try it: /social-autoposter
|
|
251
271
|
Update: npx social-autoposter update
|
|
252
272
|
```
|
|
273
|
+
|
|
274
|
+
Tell the user: "Your stats pages are ready — they'll show posts as soon as your first run completes and syncs to Neon (happens automatically after each post run). Bookmark the links above."
|
package/skill/SKILL.md
CHANGED
|
@@ -18,6 +18,10 @@ Automates finding, posting, and tracking social media comments and original post
|
|
|
18
18
|
| `/social-autoposter engage` | Scan and reply to responses on our posts |
|
|
19
19
|
| `/social-autoposter audit` | Full browser audit of all posts |
|
|
20
20
|
|
|
21
|
+
**View your posts live:** `https://s4l.ai/stats/[your_handle]`
|
|
22
|
+
— e.g. `https://s4l.ai/stats/m13v_` (Twitter handle without `@`), `https://s4l.ai/stats/Deep_Ad1959` (Reddit), `https://s4l.ai/stats/matthew-autoposter` (Moltbook).
|
|
23
|
+
The handles come from `config.json → accounts.*.handle/username`. Each platform account has its own URL.
|
|
24
|
+
|
|
21
25
|
---
|
|
22
26
|
|
|
23
27
|
## FIRST: Read config
|
|
@@ -37,7 +41,7 @@ Key fields you'll use throughout every workflow:
|
|
|
37
41
|
- `subreddits` — list of subreddits to monitor and post in
|
|
38
42
|
- `content_angle` — the user's unique perspective for writing authentic comments
|
|
39
43
|
- `projects` — products/repos to mention naturally when relevant (each has `name`, `description`, `website`, `github`, `topics`)
|
|
40
|
-
- `database` —
|
|
44
|
+
- `database` — unused (Neon Postgres via `DATABASE_URL` in `.env`)
|
|
41
45
|
|
|
42
46
|
Use these values everywhere below instead of any hardcoded names or links.
|
|
43
47
|
|
|
@@ -60,7 +64,7 @@ python3 ~/social-autoposter/scripts/update_stats.py --quiet
|
|
|
60
64
|
### 1. Rate limit check
|
|
61
65
|
|
|
62
66
|
```sql
|
|
63
|
-
SELECT COUNT(*) FROM posts WHERE posted_at >=
|
|
67
|
+
SELECT COUNT(*) FROM posts WHERE posted_at >= NOW() - INTERVAL '24 hours'
|
|
64
68
|
```
|
|
65
69
|
Max 40 posts per 24 hours. Stop if at limit.
|
|
66
70
|
|
|
@@ -123,7 +127,7 @@ Verify: fetch post by UUID, check `verification_status` is `"verified"`.
|
|
|
123
127
|
INSERT INTO posts (platform, thread_url, thread_author, thread_author_handle,
|
|
124
128
|
thread_title, thread_content, our_url, our_content, our_account,
|
|
125
129
|
source_summary, status, posted_at)
|
|
126
|
-
VALUES (
|
|
130
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'active', NOW());
|
|
127
131
|
```
|
|
128
132
|
|
|
129
133
|
Use the account value from `config.json` for `our_account`.
|
|
@@ -144,7 +148,7 @@ Max 1 original post per 24 hours. Max 3 per week.
|
|
|
144
148
|
|
|
145
149
|
```sql
|
|
146
150
|
SELECT platform, thread_title, posted_at FROM posts
|
|
147
|
-
WHERE source_summary LIKE '%' ||
|
|
151
|
+
WHERE source_summary LIKE '%' || %s || '%' AND posted_at >= NOW() - INTERVAL '30 days'
|
|
148
152
|
ORDER BY posted_at DESC;
|
|
149
153
|
```
|
|
150
154
|
|
|
@@ -180,7 +184,7 @@ Choose the single best subreddit from `config.json → subreddits` for this topi
|
|
|
180
184
|
INSERT INTO posts (platform, thread_url, thread_author, thread_author_handle,
|
|
181
185
|
thread_title, thread_content, our_url, our_content, our_account,
|
|
182
186
|
source_summary, status, posted_at)
|
|
183
|
-
VALUES (
|
|
187
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'active', NOW());
|
|
184
188
|
```
|
|
185
189
|
|
|
186
190
|
For original posts: `thread_url` = `our_url`, `thread_author` = our account from config.json.
|
|
@@ -201,6 +205,8 @@ After posting, you MUST:
|
|
|
201
205
|
python3 ~/social-autoposter/scripts/update_stats.py
|
|
202
206
|
```
|
|
203
207
|
|
|
208
|
+
After running, view updated stats at `https://s4l.ai/stats/[handle]`. Changes appear on the website within ~5 minutes.
|
|
209
|
+
|
|
204
210
|
---
|
|
205
211
|
|
|
206
212
|
## Workflow: Engage (`/social-autoposter engage`)
|
|
@@ -223,8 +229,8 @@ Draft replies: 2-4 sentences, casual, expand the topic. Apply Tiered Reply Strat
|
|
|
223
229
|
|
|
224
230
|
Post via browser (Reddit/X) or API (Moltbook). Update:
|
|
225
231
|
```sql
|
|
226
|
-
UPDATE replies SET status='replied', our_reply_content
|
|
227
|
-
replied_at=
|
|
232
|
+
UPDATE replies SET status='replied', our_reply_content=%s, our_reply_url=%s,
|
|
233
|
+
replied_at=NOW() WHERE id=%s
|
|
228
234
|
```
|
|
229
235
|
|
|
230
236
|
### Phase C: X/Twitter replies (browser required)
|
package/skill/engage.sh
CHANGED
|
@@ -12,10 +12,14 @@ set -euo pipefail
|
|
|
12
12
|
[ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
|
|
13
13
|
|
|
14
14
|
REPO_DIR="$HOME/social-autoposter"
|
|
15
|
-
DB="$REPO_DIR/social_posts.db"
|
|
16
15
|
SKILL_FILE="$REPO_DIR/skill/SKILL.md"
|
|
17
16
|
LOG_DIR="$REPO_DIR/skill/logs"
|
|
18
17
|
|
|
18
|
+
if [ -z "${DATABASE_URL:-}" ]; then
|
|
19
|
+
echo "ERROR: DATABASE_URL not set in ~/social-autoposter/.env"
|
|
20
|
+
exit 1
|
|
21
|
+
fi
|
|
22
|
+
|
|
19
23
|
mkdir -p "$LOG_DIR"
|
|
20
24
|
LOG_FILE="$LOG_DIR/engage-$(date +%Y-%m-%d_%H%M%S).log"
|
|
21
25
|
|
|
@@ -27,12 +31,12 @@ log "=== Engagement Loop Run: $(date) ==="
|
|
|
27
31
|
# PHASE A: Scan for replies (Python, no Claude needed)
|
|
28
32
|
# ═══════════════════════════════════════════════════════
|
|
29
33
|
log "Phase A: Scanning for replies..."
|
|
30
|
-
python3 "$REPO_DIR/scripts/scan_replies.py"
|
|
34
|
+
python3 "$REPO_DIR/scripts/scan_replies.py" 2>&1 | tee -a "$LOG_FILE" || true
|
|
31
35
|
|
|
32
36
|
# ═══════════════════════════════════════════════════════
|
|
33
37
|
# PHASE B: X/Twitter discovery + all reply engagement
|
|
34
38
|
# ═══════════════════════════════════════════════════════
|
|
35
|
-
PENDING_COUNT=$(
|
|
39
|
+
PENDING_COUNT=$(psql "$DATABASE_URL" -t -A -c "SELECT COUNT(*) FROM replies WHERE status='pending';")
|
|
36
40
|
log "Phase B: $PENDING_COUNT pending replies to handle"
|
|
37
41
|
|
|
38
42
|
# Always run Phase B — it handles both X/Twitter discovery and pending replies
|
|
@@ -63,17 +67,19 @@ There are $PENDING_COUNT pending replies in the database.
|
|
|
63
67
|
- **Tier 3 (direct ask):** They ask for link/tool/source. Give it immediately.
|
|
64
68
|
|
|
65
69
|
$(if [ "$PENDING_COUNT" -gt 0 ]; then
|
|
66
|
-
|
|
67
|
-
SELECT
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
psql "$DATABASE_URL" -t -A -c "
|
|
71
|
+
SELECT json_agg(q) FROM (
|
|
72
|
+
SELECT r.id, r.platform, r.their_author, r.their_content, r.their_comment_url,
|
|
73
|
+
r.their_comment_id, r.depth,
|
|
74
|
+
p.thread_title, p.thread_url, p.our_content, p.our_url,
|
|
75
|
+
CASE WHEN p.thread_url = p.our_url THEN 1 ELSE 0 END as is_our_original_post
|
|
76
|
+
FROM replies r
|
|
77
|
+
JOIN posts p ON r.post_id = p.id
|
|
78
|
+
WHERE r.status='pending'
|
|
79
|
+
ORDER BY
|
|
80
|
+
CASE WHEN p.thread_url = p.our_url THEN 0 ELSE 1 END,
|
|
81
|
+
r.discovered_at ASC
|
|
82
|
+
) q;"
|
|
77
83
|
else
|
|
78
84
|
echo "No pending replies."
|
|
79
85
|
fi)
|
|
@@ -88,21 +94,13 @@ CRITICAL: Close browser tabs after every page visit (browser_tabs action 'close'
|
|
|
88
94
|
# ═══════════════════════════════════════════════════════
|
|
89
95
|
log "Phase C: Cleanup"
|
|
90
96
|
|
|
91
|
-
TOTAL_PENDING=$(
|
|
92
|
-
TOTAL_REPLIED=$(
|
|
93
|
-
TOTAL_SKIPPED=$(
|
|
94
|
-
TOTAL_ERRORS=$(
|
|
97
|
+
TOTAL_PENDING=$(psql "$DATABASE_URL" -t -A -c "SELECT COUNT(*) FROM replies WHERE status='pending';")
|
|
98
|
+
TOTAL_REPLIED=$(psql "$DATABASE_URL" -t -A -c "SELECT COUNT(*) FROM replies WHERE status='replied';")
|
|
99
|
+
TOTAL_SKIPPED=$(psql "$DATABASE_URL" -t -A -c "SELECT COUNT(*) FROM replies WHERE status='skipped';")
|
|
100
|
+
TOTAL_ERRORS=$(psql "$DATABASE_URL" -t -A -c "SELECT COUNT(*) FROM replies WHERE status='error';")
|
|
95
101
|
|
|
96
102
|
log "Replies summary: pending=$TOTAL_PENDING replied=$TOTAL_REPLIED skipped=$TOTAL_SKIPPED errors=$TOTAL_ERRORS"
|
|
97
103
|
|
|
98
|
-
# Git sync
|
|
99
|
-
cd "$REPO_DIR"
|
|
100
|
-
git add social_posts.db
|
|
101
|
-
git diff --cached --quiet || git commit -m "engage $(date '+%Y-%m-%d %H:%M')" && git push 2>/dev/null || true
|
|
102
|
-
|
|
103
|
-
# Sync SQLite → Neon Postgres
|
|
104
|
-
bash "$REPO_DIR/syncfield.sh" || true
|
|
105
|
-
|
|
106
104
|
# Delete old logs
|
|
107
105
|
find "$LOG_DIR" -name "engage-*.log" -mtime +7 -delete 2>/dev/null || true
|
|
108
106
|
|
package/skill/stats.sh
CHANGED
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
set -euo pipefail
|
|
8
8
|
|
|
9
9
|
REPO_DIR="$HOME/social-autoposter"
|
|
10
|
-
DB="$REPO_DIR/social_posts.db"
|
|
11
10
|
LOG_DIR="$REPO_DIR/skill/logs"
|
|
12
11
|
QUIET="${1:-}"
|
|
13
12
|
|
|
@@ -22,17 +21,9 @@ echo "[$(date +%H:%M:%S)] Starting stats update" | tee "$LOGFILE"
|
|
|
22
21
|
|
|
23
22
|
# Run the Python stats script
|
|
24
23
|
if [ "$QUIET" = "--quiet" ]; then
|
|
25
|
-
python3 "$REPO_DIR/scripts/update_stats.py" --
|
|
24
|
+
python3 "$REPO_DIR/scripts/update_stats.py" --quiet 2>&1 | tee -a "$LOGFILE"
|
|
26
25
|
else
|
|
27
|
-
python3 "$REPO_DIR/scripts/update_stats.py"
|
|
26
|
+
python3 "$REPO_DIR/scripts/update_stats.py" 2>&1 | tee -a "$LOGFILE"
|
|
28
27
|
fi
|
|
29
28
|
|
|
30
29
|
echo "[$(date +%H:%M:%S)] Stats update complete" | tee -a "$LOGFILE"
|
|
31
|
-
|
|
32
|
-
# Sync DB to GitHub for Datasette Lite
|
|
33
|
-
cd "$REPO_DIR"
|
|
34
|
-
git add social_posts.db
|
|
35
|
-
git diff --cached --quiet || git commit -m "stats $(date '+%Y-%m-%d %H:%M')" && git push 2>/dev/null || true
|
|
36
|
-
|
|
37
|
-
# Sync SQLite → Neon Postgres
|
|
38
|
-
bash "$REPO_DIR/syncfield.sh" || true
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
-
<plist version="1.0">
|
|
4
|
-
<dict>
|
|
5
|
-
<key>Label</key>
|
|
6
|
-
<string>com.m13v.social-autoposter</string>
|
|
7
|
-
<key>ProgramArguments</key>
|
|
8
|
-
<array>
|
|
9
|
-
<string>/bin/bash</string>
|
|
10
|
-
<string>/Users/matthewdi/.claude/skills/social-autoposter/skill/run.sh</string>
|
|
11
|
-
</array>
|
|
12
|
-
<key>StartInterval</key>
|
|
13
|
-
<integer>3600</integer>
|
|
14
|
-
<key>StandardOutPath</key>
|
|
15
|
-
<string>/Users/matthewdi/.claude/skills/social-autoposter/logs/launchd-stdout.log</string>
|
|
16
|
-
<key>StandardErrorPath</key>
|
|
17
|
-
<string>/Users/matthewdi/.claude/skills/social-autoposter/logs/launchd-stderr.log</string>
|
|
18
|
-
<key>EnvironmentVariables</key>
|
|
19
|
-
<dict>
|
|
20
|
-
<key>PATH</key>
|
|
21
|
-
<string>/Users/matthewdi/.nvm/versions/node/v20.19.4/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
|
|
22
|
-
<key>HOME</key>
|
|
23
|
-
<string>/Users/matthewdi</string>
|
|
24
|
-
</dict>
|
|
25
|
-
<key>RunAtLoad</key>
|
|
26
|
-
<true/>
|
|
27
|
-
</dict>
|
|
28
|
-
</plist>
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
-
<plist version="1.0">
|
|
4
|
-
<dict>
|
|
5
|
-
<key>Label</key>
|
|
6
|
-
<string>com.m13v.social-engage</string>
|
|
7
|
-
<key>ProgramArguments</key>
|
|
8
|
-
<array>
|
|
9
|
-
<string>/bin/bash</string>
|
|
10
|
-
<string>/Users/matthewdi/.claude/skills/social-autoposter/skill/engage.sh</string>
|
|
11
|
-
</array>
|
|
12
|
-
<key>StartInterval</key>
|
|
13
|
-
<integer>21600</integer>
|
|
14
|
-
<key>StandardOutPath</key>
|
|
15
|
-
<string>/Users/matthewdi/.claude/skills/social-autoposter/logs/launchd-engage-stdout.log</string>
|
|
16
|
-
<key>StandardErrorPath</key>
|
|
17
|
-
<string>/Users/matthewdi/.claude/skills/social-autoposter/logs/launchd-engage-stderr.log</string>
|
|
18
|
-
<key>EnvironmentVariables</key>
|
|
19
|
-
<dict>
|
|
20
|
-
<key>PATH</key>
|
|
21
|
-
<string>/Users/matthewdi/.nvm/versions/node/v20.19.4/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
|
|
22
|
-
<key>HOME</key>
|
|
23
|
-
<string>/Users/matthewdi</string>
|
|
24
|
-
</dict>
|
|
25
|
-
<key>RunAtLoad</key>
|
|
26
|
-
<false/>
|
|
27
|
-
</dict>
|
|
28
|
-
</plist>
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
-
<plist version="1.0">
|
|
4
|
-
<dict>
|
|
5
|
-
<key>Label</key>
|
|
6
|
-
<string>com.m13v.social-stats</string>
|
|
7
|
-
<key>ProgramArguments</key>
|
|
8
|
-
<array>
|
|
9
|
-
<string>/bin/bash</string>
|
|
10
|
-
<string>/Users/matthewdi/.claude/skills/social-autoposter/skill/stats.sh</string>
|
|
11
|
-
</array>
|
|
12
|
-
<key>StartInterval</key>
|
|
13
|
-
<integer>21600</integer>
|
|
14
|
-
<key>StandardOutPath</key>
|
|
15
|
-
<string>/Users/matthewdi/.claude/skills/social-autoposter/logs/launchd-stats-stdout.log</string>
|
|
16
|
-
<key>StandardErrorPath</key>
|
|
17
|
-
<string>/Users/matthewdi/.claude/skills/social-autoposter/logs/launchd-stats-stderr.log</string>
|
|
18
|
-
<key>EnvironmentVariables</key>
|
|
19
|
-
<dict>
|
|
20
|
-
<key>PATH</key>
|
|
21
|
-
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
22
|
-
<key>HOME</key>
|
|
23
|
-
<string>/Users/matthewdi</string>
|
|
24
|
-
</dict>
|
|
25
|
-
<key>RunAtLoad</key>
|
|
26
|
-
<false/>
|
|
27
|
-
</dict>
|
|
28
|
-
</plist>
|
package/syncfield.sh
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# syncfield.sh — Sync SQLite → Neon Postgres (idempotent upsert)
|
|
3
|
-
# Called after git push in stats.sh and engage.sh
|
|
4
|
-
# Requires: sqlite3, psql, DATABASE_URL in .env
|
|
5
|
-
|
|
6
|
-
set -euo pipefail
|
|
7
|
-
|
|
8
|
-
DB="$HOME/social-autoposter/social_posts.db"
|
|
9
|
-
|
|
10
|
-
# Load secrets
|
|
11
|
-
# shellcheck source=/dev/null
|
|
12
|
-
[ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
|
|
13
|
-
|
|
14
|
-
if [ -z "${DATABASE_URL:-}" ]; then
|
|
15
|
-
echo "syncfield: DATABASE_URL not set, skipping sync"
|
|
16
|
-
exit 0
|
|
17
|
-
fi
|
|
18
|
-
|
|
19
|
-
TMPDIR="${TMPDIR:-/tmp}"
|
|
20
|
-
|
|
21
|
-
sync_table() {
|
|
22
|
-
local table="$1"
|
|
23
|
-
local columns="$2"
|
|
24
|
-
local conflict_col="${3:-id}"
|
|
25
|
-
local csv_file="$TMPDIR/syncfield_${table}.csv"
|
|
26
|
-
|
|
27
|
-
# Export from SQLite as CSV
|
|
28
|
-
sqlite3 -header -csv "$DB" "SELECT $columns FROM $table;" > "$csv_file"
|
|
29
|
-
|
|
30
|
-
local row_count
|
|
31
|
-
row_count=$(wc -l < "$csv_file" | tr -d ' ')
|
|
32
|
-
row_count=$((row_count - 1)) # subtract header
|
|
33
|
-
|
|
34
|
-
if [ "$row_count" -le 0 ]; then
|
|
35
|
-
rm -f "$csv_file"
|
|
36
|
-
return
|
|
37
|
-
fi
|
|
38
|
-
|
|
39
|
-
# Build column list for SET clause (exclude conflict column)
|
|
40
|
-
local set_clause=""
|
|
41
|
-
IFS=',' read -ra cols <<< "$columns"
|
|
42
|
-
for col in "${cols[@]}"; do
|
|
43
|
-
col=$(echo "$col" | tr -d ' ')
|
|
44
|
-
if [ "$col" != "$conflict_col" ]; then
|
|
45
|
-
if [ -n "$set_clause" ]; then
|
|
46
|
-
set_clause="$set_clause, "
|
|
47
|
-
fi
|
|
48
|
-
set_clause="${set_clause}${col} = EXCLUDED.${col}"
|
|
49
|
-
fi
|
|
50
|
-
done
|
|
51
|
-
|
|
52
|
-
# Upsert via temp table + INSERT ON CONFLICT
|
|
53
|
-
psql "$DATABASE_URL" -q <<SQL
|
|
54
|
-
CREATE TEMP TABLE _tmp_${table} (LIKE ${table} INCLUDING ALL);
|
|
55
|
-
\\copy _tmp_${table}($columns) FROM '$csv_file' WITH (FORMAT csv, HEADER true, NULL '');
|
|
56
|
-
INSERT INTO ${table}($columns)
|
|
57
|
-
SELECT $columns FROM _tmp_${table}
|
|
58
|
-
ON CONFLICT ($conflict_col) DO UPDATE SET $set_clause;
|
|
59
|
-
DROP TABLE _tmp_${table};
|
|
60
|
-
SQL
|
|
61
|
-
|
|
62
|
-
echo "syncfield: synced $table ($row_count rows)"
|
|
63
|
-
rm -f "$csv_file"
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
# Sync each table
|
|
67
|
-
sync_table "posts" "id,platform,thread_url,thread_author,thread_author_handle,thread_title,thread_content,thread_engagement,our_url,our_content,our_account,posted_at,discovered_at,status,status_checked_at,engagement_updated_at,upvotes,comments_count,views,source_turn_id,source_summary,top_comment_author,top_comment_content,top_comment_upvotes,top_comment_url"
|
|
68
|
-
|
|
69
|
-
sync_table "campaigns" "id,name,prompt,platforms,status,max_posts_per_day,posts_made,created_at,updated_at"
|
|
70
|
-
|
|
71
|
-
sync_table "replies" "id,post_id,platform,their_comment_id,their_author,their_content,their_comment_url,our_reply_id,our_reply_content,our_reply_url,parent_reply_id,moltbook_post_uuid,moltbook_parent_comment_uuid,depth,status,skip_reason,discovered_at,replied_at"
|
|
72
|
-
|
|
73
|
-
sync_table "thread_comments" "id,thread_id,author,author_handle,content,engagement,discovered_at"
|
|
74
|
-
|
|
75
|
-
# Update sync timestamp
|
|
76
|
-
psql "$DATABASE_URL" -q -c "INSERT INTO _syncfield_meta (key, value) VALUES ('last_sync', NOW()::text) ON CONFLICT (key) DO UPDATE SET value = NOW()::text;"
|
|
77
|
-
|
|
78
|
-
echo "syncfield: sync complete at $(date '+%Y-%m-%d %H:%M:%S')"
|