social-autoposter 1.5.0 → 1.6.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/bin/cli.js +113 -6
- package/bin/server.js +265 -14
- package/browser-agent-configs/all-agents-mcp.json +49 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/mcp-servers/browser-harness/server.py +600 -0
- package/package.json +3 -2
- package/scripts/dm_conversation.py +1 -1
- package/scripts/dm_short_links.py +2 -2
- package/scripts/engage_reddit.py +21 -8
- package/scripts/engagement_styles.py +17 -165
- package/scripts/fetch_prospect_profile.py +1 -1
- package/scripts/github_tools.py +5 -3
- package/scripts/log_claude_session.py +128 -69
- package/scripts/reddit_browser.py +64 -0
- package/scripts/reddit_tools.py +7 -0
- package/scripts/run_moltbook_cycle.py +4 -2
- package/scripts/twitter_browser.py +57 -65
- package/scripts/twitter_gen_links.py +1 -1
- package/scripts/twitter_post_plan.py +2 -2
- package/skill/dm-outreach-twitter.sh +2 -8
- package/skill/engage-dm-replies.sh +29 -9
- package/skill/engage.sh +1 -1
- package/skill/lib/twitter-backend.sh +45 -6
- package/skill/octolens.sh +2 -2
- package/skill/run-twitter-cycle.sh +5 -20
- package/skill/run-twitter-threads.sh +7 -6
- package/skill/scan-twitter-followups.sh +2 -2
- package/browser-agent-configs/twitter-agent-mcp.json +0 -16
- package/browser-agent-configs/twitter-agent.json +0 -17
package/bin/cli.js
CHANGED
|
@@ -23,6 +23,7 @@ const COPY_TARGETS = [
|
|
|
23
23
|
'skill',
|
|
24
24
|
'setup',
|
|
25
25
|
'browser-agent-configs',
|
|
26
|
+
'mcp-servers',
|
|
26
27
|
];
|
|
27
28
|
|
|
28
29
|
const ENV_TEMPLATE = `# social-autoposter environment variables
|
|
@@ -42,16 +43,20 @@ DATABASE_URL=
|
|
|
42
43
|
const USER_FILES = new Set(['config.json', '.env', 'SKILL.md']);
|
|
43
44
|
|
|
44
45
|
// Browser agent config templates -> install path under ~/.claude/browser-agent-configs/
|
|
46
|
+
// twitter-harness replaces the retired twitter-agent (2026-05-19). The harness
|
|
47
|
+
// runs a CDP-driven real Chrome on port 9555 backed by an MCP stdio server at
|
|
48
|
+
// ~/.claude/mcp-servers/browser-harness/server.py. installBrowserHarness()
|
|
49
|
+
// below provisions the supporting bits (uv, browser-harness CLI, mcp pkg).
|
|
45
50
|
const BROWSER_AGENT_CONFIGS = [
|
|
46
|
-
'twitter-agent-mcp.json',
|
|
47
|
-
'twitter-agent.json',
|
|
48
51
|
'reddit-agent-mcp.json',
|
|
49
52
|
'reddit-agent.json',
|
|
50
53
|
'linkedin-agent-mcp.json',
|
|
51
54
|
'linkedin-agent.json',
|
|
55
|
+
'twitter-harness-mcp.json',
|
|
56
|
+
'all-agents-mcp.json',
|
|
52
57
|
];
|
|
53
58
|
|
|
54
|
-
const BROWSER_PROFILES = ['
|
|
59
|
+
const BROWSER_PROFILES = ['reddit', 'linkedin', 'browser-harness'];
|
|
55
60
|
|
|
56
61
|
function copyDir(src, dest) {
|
|
57
62
|
fs.mkdirSync(dest, { recursive: true });
|
|
@@ -71,8 +76,27 @@ function linkOrRelink(target, linkPath) {
|
|
|
71
76
|
fs.symlinkSync(target, linkPath);
|
|
72
77
|
}
|
|
73
78
|
|
|
79
|
+
// Locate uv (Astral's Python launcher). The browser-harness MCP server is
|
|
80
|
+
// shebanged through uv so it can pull `mcp` on first run without polluting
|
|
81
|
+
// the system Python. Returns the absolute path if found, or empty string.
|
|
82
|
+
function findUvBin() {
|
|
83
|
+
const candidates = [
|
|
84
|
+
path.join(HOME, '.local', 'bin', 'uv'),
|
|
85
|
+
'/opt/homebrew/bin/uv',
|
|
86
|
+
'/usr/local/bin/uv',
|
|
87
|
+
'/usr/bin/uv',
|
|
88
|
+
];
|
|
89
|
+
for (const c of candidates) {
|
|
90
|
+
if (fs.existsSync(c)) return c;
|
|
91
|
+
}
|
|
92
|
+
const which = spawnSync('command', ['-v', 'uv'], { shell: true, encoding: 'utf8' });
|
|
93
|
+
const found = (which.stdout || '').trim().split('\n')[0];
|
|
94
|
+
return found && fs.existsSync(found) ? found : '';
|
|
95
|
+
}
|
|
96
|
+
|
|
74
97
|
function installBrowserAgentConfigs() {
|
|
75
98
|
const nodeBin = path.dirname(process.execPath);
|
|
99
|
+
const uvBin = findUvBin() || path.join(HOME, '.local', 'bin', 'uv');
|
|
76
100
|
const srcDir = path.join(PKG_ROOT, 'browser-agent-configs');
|
|
77
101
|
const destDir = path.join(HOME, '.claude', 'browser-agent-configs');
|
|
78
102
|
fs.mkdirSync(destDir, { recursive: true });
|
|
@@ -90,7 +114,8 @@ function installBrowserAgentConfigs() {
|
|
|
90
114
|
const tpl = fs.readFileSync(src, 'utf8');
|
|
91
115
|
const out = tpl
|
|
92
116
|
.replace(/__HOME__/g, HOME)
|
|
93
|
-
.replace(/__NODE_BIN__/g, nodeBin)
|
|
117
|
+
.replace(/__NODE_BIN__/g, nodeBin)
|
|
118
|
+
.replace(/__UV_BIN__/g, uvBin);
|
|
94
119
|
fs.writeFileSync(dest, out);
|
|
95
120
|
installed++;
|
|
96
121
|
}
|
|
@@ -105,16 +130,92 @@ function installBrowserAgentConfigs() {
|
|
|
105
130
|
console.log(` browser profile dirs ready -> ${profilesDir}/{${BROWSER_PROFILES.join(',')}}`);
|
|
106
131
|
}
|
|
107
132
|
|
|
133
|
+
// Provision the browser-harness toolchain that backs the twitter-harness MCP:
|
|
134
|
+
// 1. install uv (Astral) if missing
|
|
135
|
+
// 2. git-clone browser-use/browser-harness
|
|
136
|
+
// 3. uv tool install -e . (provides the `browser-harness` CLI)
|
|
137
|
+
// 4. ensure `mcp` Python package is importable for server.py
|
|
138
|
+
// 5. copy our shipped server.py into ~/.claude/mcp-servers/browser-harness/
|
|
139
|
+
// All steps are idempotent.
|
|
140
|
+
function installBrowserHarness() {
|
|
141
|
+
console.log(' setting up browser-harness (twitter-harness MCP backend)...');
|
|
142
|
+
|
|
143
|
+
// Step 1: uv. Try the official installer first; fall back to pip.
|
|
144
|
+
let uvBin = findUvBin();
|
|
145
|
+
if (!uvBin) {
|
|
146
|
+
console.log(' uv not found -> installing via Astral installer');
|
|
147
|
+
const sh = spawnSync('bash', ['-lc', 'curl -LsSf https://astral.sh/uv/install.sh | sh'], { stdio: 'inherit' });
|
|
148
|
+
if (sh.status !== 0) {
|
|
149
|
+
console.log(' Astral installer failed; falling back to pip3 install uv');
|
|
150
|
+
let pip = spawnSync('pip3', ['install', '-q', 'uv'], { stdio: 'inherit' });
|
|
151
|
+
if (pip.status !== 0) {
|
|
152
|
+
pip = spawnSync('pip3', ['install', '-q', 'uv', '--break-system-packages'], { stdio: 'inherit' });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
uvBin = findUvBin();
|
|
156
|
+
}
|
|
157
|
+
if (!uvBin) {
|
|
158
|
+
console.warn(' WARNING: uv install failed; twitter-harness MCP server.py will not start.');
|
|
159
|
+
console.warn(' Install manually: curl -LsSf https://astral.sh/uv/install.sh | sh');
|
|
160
|
+
} else {
|
|
161
|
+
console.log(` uv -> ${uvBin}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Step 2 + 3: clone + `uv tool install -e .` browser-harness.
|
|
165
|
+
const harnessDir = path.join(HOME, 'Developer', 'browser-harness');
|
|
166
|
+
if (!fs.existsSync(harnessDir)) {
|
|
167
|
+
fs.mkdirSync(path.dirname(harnessDir), { recursive: true });
|
|
168
|
+
console.log(' cloning browser-harness from GitHub...');
|
|
169
|
+
const clone = spawnSync('git', ['clone', '--depth', '1', 'https://github.com/browser-use/browser-harness', harnessDir], { stdio: 'inherit' });
|
|
170
|
+
if (clone.status !== 0) {
|
|
171
|
+
console.warn(' WARNING: git clone failed; twitter-harness will not work until you clone manually.');
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
console.log(` browser-harness clone exists -> ${harnessDir}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (uvBin && fs.existsSync(harnessDir)) {
|
|
178
|
+
console.log(' installing browser-harness CLI via uv tool...');
|
|
179
|
+
const install = spawnSync(uvBin, ['tool', 'install', '-e', harnessDir], { stdio: 'inherit' });
|
|
180
|
+
if (install.status !== 0) {
|
|
181
|
+
console.warn(' WARNING: `uv tool install -e .` failed; check the output above.');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Step 4: ensure mcp Python package available (server.py uses `from mcp.server.fastmcp ...`).
|
|
186
|
+
// server.py is shebanged through `uv run --with mcp ...` so this is belt-and-suspenders;
|
|
187
|
+
// we install it into the system Python too so a plain `python3 server.py` also works.
|
|
188
|
+
console.log(' ensuring mcp>=1.0.0 Python package is importable...');
|
|
189
|
+
let pip = spawnSync('pip3', ['install', '-q', 'mcp>=1.0.0'], { stdio: 'inherit' });
|
|
190
|
+
if (pip.status !== 0) {
|
|
191
|
+
pip = spawnSync('pip3', ['install', '-q', 'mcp>=1.0.0', '--break-system-packages'], { stdio: 'inherit' });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Step 5: copy our shipped server.py into the canonical install location.
|
|
195
|
+
const srcServer = path.join(PKG_ROOT, 'mcp-servers', 'browser-harness', 'server.py');
|
|
196
|
+
const destServer = path.join(HOME, '.claude', 'mcp-servers', 'browser-harness', 'server.py');
|
|
197
|
+
if (fs.existsSync(srcServer)) {
|
|
198
|
+
fs.mkdirSync(path.dirname(destServer), { recursive: true });
|
|
199
|
+
fs.copyFileSync(srcServer, destServer);
|
|
200
|
+
try { fs.chmodSync(destServer, 0o755); } catch {}
|
|
201
|
+
console.log(` server.py -> ${destServer}`);
|
|
202
|
+
} else {
|
|
203
|
+
console.warn(` WARNING: package missing mcp-servers/browser-harness/server.py (${srcServer})`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
108
207
|
// Register the three browser-agent MCP servers with Claude so they show up
|
|
109
208
|
// under user scope (writes to ~/.claude.json). Idempotent: parses the output
|
|
110
209
|
// of `claude mcp list` and only calls `add-json` for missing entries.
|
|
111
210
|
// If the `claude` CLI is not on PATH, prints manual instructions and returns.
|
|
112
211
|
function registerBrowserAgentMcpServers() {
|
|
113
212
|
const configDir = path.join(HOME, '.claude', 'browser-agent-configs');
|
|
213
|
+
// twitter-agent retired 2026-05-19, replaced by twitter-harness (CDP-driven
|
|
214
|
+
// real Chrome on port 9555 via the browser-harness MCP server).
|
|
114
215
|
const servers = [
|
|
115
|
-
{ name: 'twitter-agent', file: path.join(configDir, 'twitter-agent-mcp.json') },
|
|
116
216
|
{ name: 'reddit-agent', file: path.join(configDir, 'reddit-agent-mcp.json') },
|
|
117
217
|
{ name: 'linkedin-agent', file: path.join(configDir, 'linkedin-agent-mcp.json') },
|
|
218
|
+
{ name: 'twitter-harness', file: path.join(configDir, 'twitter-harness-mcp.json') },
|
|
118
219
|
];
|
|
119
220
|
|
|
120
221
|
const claudeBin = spawnSync('claude', ['--version'], { stdio: 'pipe' });
|
|
@@ -138,7 +239,7 @@ function registerBrowserAgentMcpServers() {
|
|
|
138
239
|
continue;
|
|
139
240
|
}
|
|
140
241
|
// `claude mcp list` prints one server per line starting with the name.
|
|
141
|
-
// Use a word-boundary check so
|
|
242
|
+
// Use a word-boundary check so e.g. reddit-agent does not false-match linkedin-agent.
|
|
142
243
|
const re = new RegExp(`(^|\\s)${s.name.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}(:|\\s|$)`, 'm');
|
|
143
244
|
if (re.test(existing)) {
|
|
144
245
|
skipped++;
|
|
@@ -288,6 +389,9 @@ function init() {
|
|
|
288
389
|
installSystemdUnits();
|
|
289
390
|
}
|
|
290
391
|
|
|
392
|
+
// Provision the browser-harness toolchain BEFORE writing harness configs so
|
|
393
|
+
// findUvBin() picks up a freshly-installed uv on first run.
|
|
394
|
+
installBrowserHarness();
|
|
291
395
|
// Install browser agent MCP configs + profile dirs (skips existing files)
|
|
292
396
|
installBrowserAgentConfigs();
|
|
293
397
|
// Register those MCP servers with Claude so they show up in `claude mcp list`.
|
|
@@ -369,6 +473,9 @@ function update() {
|
|
|
369
473
|
installSystemdUnits();
|
|
370
474
|
}
|
|
371
475
|
|
|
476
|
+
// Provision browser-harness (uv + clone + uv tool install + mcp pkg + server.py).
|
|
477
|
+
// Idempotent: skips steps that are already done.
|
|
478
|
+
installBrowserHarness();
|
|
372
479
|
// Top up browser agent configs (won't overwrite user customizations)
|
|
373
480
|
installBrowserAgentConfigs();
|
|
374
481
|
// Register any newly added MCP servers with Claude (idempotent).
|
package/bin/server.js
CHANGED
|
@@ -5727,16 +5727,24 @@ async function handleApi(req, res) {
|
|
|
5727
5727
|
"COALESCE(comments_count, 0)::int AS comments_count, " +
|
|
5728
5728
|
"CASE WHEN LOWER(posts.platform) IN ('moltbook', 'github', 'github_issues') " +
|
|
5729
5729
|
"THEN NULL ELSE COALESCE(views, 0)::int END AS views, " +
|
|
5730
|
-
//
|
|
5731
|
-
//
|
|
5732
|
-
//
|
|
5733
|
-
//
|
|
5734
|
-
//
|
|
5735
|
-
|
|
5730
|
+
// 2026-05-19: per-post score now matches the unified composite used
|
|
5731
|
+
// by top_performers.SCORE_SQL, the engagement_styles.py picker, and
|
|
5732
|
+
// the dashboard "Posts by Engagement Style" section:
|
|
5733
|
+
// score = clicks×10 + comments×3 + upvotes_discounted
|
|
5734
|
+
// Click weight ×10 because a real human click outvalues 10 likes of
|
|
5735
|
+
// vibes when ranking engagement quality. Views are deliberately
|
|
5736
|
+
// excluded (viral-by-algorithm ≠ a pattern worth imitating).
|
|
5737
|
+
// Reddit/Moltbook subtract 1 from upvotes to strip the OP self-upvote.
|
|
5738
|
+
// Clicks come from `pl.real_clicks` = the bot-filtered per-hit log
|
|
5739
|
+
// (post_link_clicks). Posts predating the 2026-05-07 log start get 0
|
|
5740
|
+
// clicks here even if they accumulated PostHog $pageview backfill —
|
|
5741
|
+
// by design: the picker uses the same source so the score the model
|
|
5742
|
+
// sees and the score the dashboard shows agree on the same ground truth.
|
|
5743
|
+
"(COALESCE(pl.real_clicks, 0) * 10 " +
|
|
5744
|
+
"+ COALESCE(comments_count,0) * 3 " +
|
|
5736
5745
|
"+ CASE WHEN LOWER(posts.platform) IN ('reddit', 'moltbook') " +
|
|
5737
|
-
"THEN GREATEST(0, COALESCE(upvotes,0) - 1)
|
|
5738
|
-
"ELSE COALESCE(upvotes,0)
|
|
5739
|
-
"+ COALESCE(views,0) / 100)::int AS score, " +
|
|
5746
|
+
"THEN GREATEST(0, COALESCE(upvotes,0) - 1) " +
|
|
5747
|
+
"ELSE COALESCE(upvotes,0) END)::int AS score, " +
|
|
5740
5748
|
"(our_url IS NOT NULL AND thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account)) AS is_thread, " +
|
|
5741
5749
|
"posted_at, engagement_updated_at, our_content, our_url, thread_url, thread_title, " +
|
|
5742
5750
|
"LEFT(COALESCE(thread_content, ''), 400) AS thread_content, " +
|
|
@@ -5789,10 +5797,13 @@ async function handleApi(req, res) {
|
|
|
5789
5797
|
"GREATEST(0, COALESCE(r2.upvotes, 0) - 1)::int AS upvotes, " +
|
|
5790
5798
|
"COALESCE(r2.comments_count, 0)::int AS comments_count, " +
|
|
5791
5799
|
"COALESCE(r2.views, 0)::int AS views, " +
|
|
5792
|
-
//
|
|
5793
|
-
|
|
5794
|
-
|
|
5795
|
-
|
|
5800
|
+
// 2026-05-19: aligned with the unified composite (see posts branch
|
|
5801
|
+
// for full rationale). Replies have no associated `post_links` row
|
|
5802
|
+
// (we don't mint short links for reply-to-comment threads), so the
|
|
5803
|
+
// clicks term is implicitly 0 here. Score = comments×3 + upvotes_net.
|
|
5804
|
+
// Reddit-only branch → always discount the self-upvote.
|
|
5805
|
+
"(COALESCE(r2.comments_count,0) * 3 " +
|
|
5806
|
+
"+ GREATEST(0, COALESCE(r2.upvotes,0) - 1))::int AS score, " +
|
|
5796
5807
|
"FALSE AS is_thread, " +
|
|
5797
5808
|
"r2.replied_at AS posted_at, " +
|
|
5798
5809
|
"r2.engagement_updated_at, " +
|
|
@@ -6138,6 +6149,111 @@ async function handleApi(req, res) {
|
|
|
6138
6149
|
return;
|
|
6139
6150
|
}
|
|
6140
6151
|
|
|
6152
|
+
// GET /api/funnel/stats/by_subreddit - Reddit-only post-engagement aggregated
|
|
6153
|
+
// per subreddit. Mirrors the post-engagement columns of /api/funnel/stats
|
|
6154
|
+
// (Posts, Upvotes, Comments, Views, Post Clicks) but the row identity is the
|
|
6155
|
+
// subreddit slug (extracted from posts.thread_url) instead of project_name.
|
|
6156
|
+
// Funnel cells (pageviews/CTAs/bookings) are NOT included because they are
|
|
6157
|
+
// project-level signals from PostHog/Bookings DBs and can't be attributed to
|
|
6158
|
+
// a subreddit. The `projects` array per row tells which projects posted
|
|
6159
|
+
// there in the window so admins can see overlap.
|
|
6160
|
+
if (p === '/api/funnel/stats/by_subreddit' && req.method === 'GET') {
|
|
6161
|
+
const url = new URL(req.url, 'http://localhost');
|
|
6162
|
+
const days = Math.max(1, Math.min(90, parseInt(url.searchParams.get('days') || '7', 10) || 7));
|
|
6163
|
+
const pc = auth.projectClause(req.user, 'project_name', url.searchParams.get('project'));
|
|
6164
|
+
if (!pc.ok) return json(res, { days, subreddits: [] });
|
|
6165
|
+
const pcClause = pc.clause || '';
|
|
6166
|
+
const q = "WITH reddit_posts AS (" +
|
|
6167
|
+
"SELECT " +
|
|
6168
|
+
"LOWER(SUBSTRING(thread_url FROM 'reddit\\.com/r/([^/]+)/')) AS subreddit, " +
|
|
6169
|
+
"project_name, id, " +
|
|
6170
|
+
// Reddit's own UI subtracts the auto-self-upvote; mirror what
|
|
6171
|
+
// /api/funnel/stats does for the Upvotes column.
|
|
6172
|
+
"GREATEST(0, COALESCE(upvotes, 0) - 1) AS upvotes, " +
|
|
6173
|
+
"COALESCE(comments_count, 0) AS comments, " +
|
|
6174
|
+
"COALESCE(views, 0) AS views " +
|
|
6175
|
+
"FROM posts " +
|
|
6176
|
+
"WHERE LOWER(platform) = 'reddit' " +
|
|
6177
|
+
"AND posted_at >= NOW() - INTERVAL '" + days + " days' " +
|
|
6178
|
+
"AND project_name IS NOT NULL " +
|
|
6179
|
+
"AND our_content <> '(mention - no original post)'" +
|
|
6180
|
+
pcClause +
|
|
6181
|
+
") " +
|
|
6182
|
+
"SELECT " +
|
|
6183
|
+
"rp.subreddit, " +
|
|
6184
|
+
"array_agg(DISTINCT rp.project_name) AS projects, " +
|
|
6185
|
+
"COUNT(*)::int AS posts, " +
|
|
6186
|
+
"COALESCE(SUM(rp.upvotes), 0)::int AS upvotes, " +
|
|
6187
|
+
"COALESCE(SUM(rp.comments), 0)::int AS comments, " +
|
|
6188
|
+
"COALESCE(SUM(rp.views), 0)::int AS views, " +
|
|
6189
|
+
"COALESCE(SUM(pl.total_clicks), 0)::int AS post_clicks " +
|
|
6190
|
+
"FROM reddit_posts rp " +
|
|
6191
|
+
"LEFT JOIN (" +
|
|
6192
|
+
"SELECT post_id, SUM(clicks)::int AS total_clicks " +
|
|
6193
|
+
"FROM post_links WHERE post_id IS NOT NULL GROUP BY post_id" +
|
|
6194
|
+
") pl ON pl.post_id = rp.id " +
|
|
6195
|
+
"WHERE rp.subreddit IS NOT NULL AND rp.subreddit <> '' " +
|
|
6196
|
+
"GROUP BY rp.subreddit " +
|
|
6197
|
+
"ORDER BY upvotes DESC NULLS LAST, posts DESC " +
|
|
6198
|
+
"LIMIT 500";
|
|
6199
|
+
return (async () => {
|
|
6200
|
+
try {
|
|
6201
|
+
const rows = await pq(q);
|
|
6202
|
+
if (rows === null) return json(res, { error: 'db_unavailable' }, 500);
|
|
6203
|
+
// Load subreddit_bans.comment_blocked from config.json and build a
|
|
6204
|
+
// sub→[accounts] map so each row can surface which accounts are
|
|
6205
|
+
// banned from that sub. We're moving toward account-agnostic posting
|
|
6206
|
+
// (different machines can post the same project as different
|
|
6207
|
+
// accounts), so a single sub may have multiple banned accounts over
|
|
6208
|
+
// time. Each comment_blocked entry is {sub, added_at, reason,
|
|
6209
|
+
// account, noticed_by_project} per the 2026-05-11 audit shape.
|
|
6210
|
+
const bansBySub = new Map();
|
|
6211
|
+
try {
|
|
6212
|
+
const cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
6213
|
+
const bans = (cfg && cfg.subreddit_bans) || {};
|
|
6214
|
+
const list = Array.isArray(bans.comment_blocked) ? bans.comment_blocked : [];
|
|
6215
|
+
for (const entry of list) {
|
|
6216
|
+
if (!entry) continue;
|
|
6217
|
+
let slug = null, account = null;
|
|
6218
|
+
if (typeof entry === 'string') {
|
|
6219
|
+
slug = entry;
|
|
6220
|
+
} else if (typeof entry === 'object') {
|
|
6221
|
+
slug = entry.sub || null;
|
|
6222
|
+
account = entry.account || null;
|
|
6223
|
+
}
|
|
6224
|
+
if (!slug) continue;
|
|
6225
|
+
const key = String(slug).toLowerCase();
|
|
6226
|
+
if (!bansBySub.has(key)) bansBySub.set(key, new Set());
|
|
6227
|
+
if (account) bansBySub.get(key).add(String(account));
|
|
6228
|
+
}
|
|
6229
|
+
} catch {}
|
|
6230
|
+
const subreddits = rows.map(r => {
|
|
6231
|
+
const slug = String(r.subreddit || '').toLowerCase();
|
|
6232
|
+
const accounts = bansBySub.has(slug)
|
|
6233
|
+
? Array.from(bansBySub.get(slug)).sort()
|
|
6234
|
+
: [];
|
|
6235
|
+
return {
|
|
6236
|
+
name: r.subreddit,
|
|
6237
|
+
projects: r.projects || [],
|
|
6238
|
+
banned_accounts: accounts,
|
|
6239
|
+
posts: Number(r.posts) || 0,
|
|
6240
|
+
upvotes: Number(r.upvotes) || 0,
|
|
6241
|
+
comments: Number(r.comments) || 0,
|
|
6242
|
+
views: Number(r.views) || 0,
|
|
6243
|
+
post_clicks: Number(r.post_clicks) || 0,
|
|
6244
|
+
};
|
|
6245
|
+
});
|
|
6246
|
+
return json(res, {
|
|
6247
|
+
generated_at: new Date().toISOString(),
|
|
6248
|
+
days,
|
|
6249
|
+
subreddits,
|
|
6250
|
+
});
|
|
6251
|
+
} catch (e) {
|
|
6252
|
+
return json(res, { error: String(e && e.message || e) }, 500);
|
|
6253
|
+
}
|
|
6254
|
+
})();
|
|
6255
|
+
}
|
|
6256
|
+
|
|
6141
6257
|
// GET /api/dm/stats - per-project DM funnel (outreach, replies, interest tiers,
|
|
6142
6258
|
// qualification, bookings, conversions). Window is "active in last N days"
|
|
6143
6259
|
// (COALESCE(last_message_at, discovered_at)) to match /api/top/dms semantics.
|
|
@@ -7792,7 +7908,7 @@ const HTML = `<!DOCTYPE html>
|
|
|
7792
7908
|
<div class="style-stats-empty">Loading\u2026</div>
|
|
7793
7909
|
</div>
|
|
7794
7910
|
</details>
|
|
7795
|
-
<details class="style-stats-section" id="search-queries-stats"
|
|
7911
|
+
<details class="style-stats-section" id="search-queries-stats">
|
|
7796
7912
|
<summary>
|
|
7797
7913
|
<span class="style-stats-title"><span class="style-stats-caret">\u25B6</span><span id="search-queries-stats-heading">Search Queries (last 24 hours)</span><span class="stat-card-info" data-tooltip="Per-query stats from twitter_search_attempts + linkedin_search_attempts + reddit_search_attempts. GitHub isn\u2019t represented. Reddit rows show seed concepts (topic buckets) rather than literal search phrases. attempts = times the query was drafted and run. attempts = times the query was drafted and run. candidates_found = sum of tweets/posts the search returned. dud_rate = % of attempts that returned 0. posts_made = candidates from this query that we actually posted to. avg_engagement = comments\u00D73 + upvotes on those resulting posts (same formula as top_performers.py). Honors Window/Platform/Project filters above.">i</span></span>
|
|
7798
7914
|
<span class="style-stats-total" id="search-queries-stats-total"></span>
|
|
@@ -7808,6 +7924,15 @@ const HTML = `<!DOCTYPE html>
|
|
|
7808
7924
|
<div class="style-stats-empty">Loading\u2026</div>
|
|
7809
7925
|
</div>
|
|
7810
7926
|
</details>
|
|
7927
|
+
<details class="style-stats-section" id="subreddit-stats" open>
|
|
7928
|
+
<summary>
|
|
7929
|
+
<span class="style-stats-title"><span class="style-stats-caret">\u25B6</span><span id="subreddit-stats-heading">Per-Subreddit Stats (last 24 hours)</span><span class="stat-card-info" data-tooltip="Reddit posts only, grouped by subreddit. Same post-engagement columns as Project Funnel Stats above, but the row identity is the subreddit slug (extracted from posts.thread_url) instead of project_name. Funnel cells (pageviews / CTAs / bookings) are NOT shown here because they\u2019re project-level signals and can\u2019t be attributed to a subreddit. The Projects column lists which projects posted to that sub in the window.">i</span></span>
|
|
7930
|
+
<span class="style-stats-total" id="subreddit-stats-total"></span>
|
|
7931
|
+
</summary>
|
|
7932
|
+
<div id="subreddit-stats-body">
|
|
7933
|
+
<div class="style-stats-empty">Loading\u2026</div>
|
|
7934
|
+
</div>
|
|
7935
|
+
</details>
|
|
7811
7936
|
</div>
|
|
7812
7937
|
|
|
7813
7938
|
<div class="content hidden" id="tab-trends">
|
|
@@ -10783,6 +10908,8 @@ async function reloadStatsTabSections() {
|
|
|
10783
10908
|
if (dmEl && dmEl.open) pending.push(loadDmStats(true));
|
|
10784
10909
|
const sqEl = document.getElementById('search-queries-stats');
|
|
10785
10910
|
if (sqEl && sqEl.open) pending.push(loadSearchQueriesStats(true));
|
|
10911
|
+
const subEl = document.getElementById('subreddit-stats');
|
|
10912
|
+
if (subEl && subEl.open) pending.push(loadSubredditStats(true));
|
|
10786
10913
|
try { await Promise.allSettled(pending); }
|
|
10787
10914
|
finally { document.body.classList.remove('sa-stats-busy'); }
|
|
10788
10915
|
}
|
|
@@ -10801,6 +10928,8 @@ function syncStatsHeadings() {
|
|
|
10801
10928
|
if (dm) dm.textContent = 'DM Funnel Stats (' + win.labelLong + ')';
|
|
10802
10929
|
const sq = document.getElementById('search-queries-stats-heading');
|
|
10803
10930
|
if (sq) sq.textContent = 'Search Queries (' + win.labelLong + ')';
|
|
10931
|
+
const sub = document.getElementById('subreddit-stats-heading');
|
|
10932
|
+
if (sub) sub.textContent = 'Per-Subreddit Stats (' + win.labelLong + ')';
|
|
10804
10933
|
}
|
|
10805
10934
|
function syncStatusHeadings() {
|
|
10806
10935
|
const win = currentStatusWindow();
|
|
@@ -13204,6 +13333,87 @@ function renderFunnelStats(payload) {
|
|
|
13204
13333
|
'</div>');
|
|
13205
13334
|
}
|
|
13206
13335
|
|
|
13336
|
+
// Per-subreddit post-engagement stats. Reddit-only sibling of renderFunnelStats:
|
|
13337
|
+
// same Posts / Upvotes / Comments / Views / Post Clicks columns, but the row
|
|
13338
|
+
// identity is the subreddit slug. No funnel cells (pageviews / CTAs / bookings)
|
|
13339
|
+
// because those are project-level and can\u2019t be attributed to a subreddit.
|
|
13340
|
+
let _subredditStatsTableState = { sortField: 'upvotes', sortDir: 'desc', filters: {} };
|
|
13341
|
+
function renderSubredditStats(payload) {
|
|
13342
|
+
const body = document.getElementById('subreddit-stats-body');
|
|
13343
|
+
const totalEl = document.getElementById('subreddit-stats-total');
|
|
13344
|
+
if (!body) return;
|
|
13345
|
+
if (payload && payload.error) {
|
|
13346
|
+
if (totalEl) totalEl.textContent = 'error';
|
|
13347
|
+
body.innerHTML = '<div class="style-stats-empty">' + escapeHtml(payload.error) + '</div>';
|
|
13348
|
+
return;
|
|
13349
|
+
}
|
|
13350
|
+
const rows = (payload && payload.subreddits) || [];
|
|
13351
|
+
if (!rows.length) {
|
|
13352
|
+
if (totalEl) totalEl.textContent = '0 subreddits';
|
|
13353
|
+
body.innerHTML = '<div class="style-stats-empty">No Reddit posts in this window.</div>';
|
|
13354
|
+
return;
|
|
13355
|
+
}
|
|
13356
|
+
if (totalEl) totalEl.textContent = rows.length + ' subreddit' + (rows.length === 1 ? '' : 's');
|
|
13357
|
+
const fmt = n => (Number(n) || 0).toLocaleString();
|
|
13358
|
+
const normalized = rows.map(r => ({
|
|
13359
|
+
name: r.name || '',
|
|
13360
|
+
projects: Array.isArray(r.projects) ? r.projects.join(', ') : '',
|
|
13361
|
+
banned: Array.isArray(r.banned_accounts) ? r.banned_accounts.join(', ') : '',
|
|
13362
|
+
posts: Number(r.posts) || 0,
|
|
13363
|
+
upvotes: Number(r.upvotes) || 0,
|
|
13364
|
+
comments: Number(r.comments) || 0,
|
|
13365
|
+
views: Number(r.views) || 0,
|
|
13366
|
+
post_clicks: Number(r.post_clicks) || 0,
|
|
13367
|
+
}));
|
|
13368
|
+
const fmtSubName = v => {
|
|
13369
|
+
const slug = String(v || '').replace(/[^a-zA-Z0-9_\-]/g, '');
|
|
13370
|
+
if (!slug) return escapeHtml(v);
|
|
13371
|
+
return '<a href="https://www.reddit.com/r/' + encodeURIComponent(slug) + '/" target="_blank" rel="noopener">r/' + escapeHtml(slug) + '</a>';
|
|
13372
|
+
};
|
|
13373
|
+
const fmtProjects = v => {
|
|
13374
|
+
const s = String(v || '');
|
|
13375
|
+
if (!s) return '\u2014';
|
|
13376
|
+
return '<span style="color:var(--text-muted);font-size:12px;" title="' + escapeHtml(s) + '">' + escapeHtml(s) + '</span>';
|
|
13377
|
+
};
|
|
13378
|
+
// Banned column: comma-separated list of Reddit handles whose
|
|
13379
|
+
// subreddit_bans.comment_blocked entry matches this sub. Each handle
|
|
13380
|
+
// links to the account's Reddit profile so an admin can verify the ban
|
|
13381
|
+
// is real (e.g. confirm shadow-reject vs hard ban). Account-agnostic:
|
|
13382
|
+
// multiple machines may post the same project under different accounts,
|
|
13383
|
+
// so a sub can carry several bans (one per offending account).
|
|
13384
|
+
const fmtBannedAccounts = v => {
|
|
13385
|
+
const s = String(v || '').trim();
|
|
13386
|
+
if (!s) return '<span style="color:var(--text-muted);">\u2014</span>';
|
|
13387
|
+
const handles = s.split(',').map(x => x.trim()).filter(Boolean);
|
|
13388
|
+
const parts = handles.map(h => {
|
|
13389
|
+
const safe = h.replace(/[^a-zA-Z0-9_\-]/g, '');
|
|
13390
|
+
if (!safe) return escapeHtml(h);
|
|
13391
|
+
return '<a href="https://www.reddit.com/user/' + encodeURIComponent(safe) + '/" target="_blank" rel="noopener" style="color:var(--danger,#e74c3c);">u/' + escapeHtml(safe) + '</a>';
|
|
13392
|
+
});
|
|
13393
|
+
return parts.join(', ');
|
|
13394
|
+
};
|
|
13395
|
+
mountSortableTable({
|
|
13396
|
+
containerId: 'subreddit-stats-body',
|
|
13397
|
+
state: _subredditStatsTableState,
|
|
13398
|
+
storageKey: 'sa.subredditStatsTable.v1',
|
|
13399
|
+
rows: normalized,
|
|
13400
|
+
showTotals: true,
|
|
13401
|
+
columns: [
|
|
13402
|
+
{ key: 'name', label: 'Subreddit', type: 'text', align: 'left', formatter: fmtSubName },
|
|
13403
|
+
{ key: 'projects', label: 'Projects', type: 'text', align: 'left', formatter: fmtProjects },
|
|
13404
|
+
{ key: 'banned', label: 'Banned', type: 'text', align: 'left', formatter: fmtBannedAccounts,
|
|
13405
|
+
helpText: 'Reddit accounts whose subreddit_bans.comment_blocked entry matches this sub. Multiple handles means multiple agent accounts have been banned from posting here. "\u2014" means no recorded ban for this sub. Source: config.json subreddit_bans.comment_blocked.' },
|
|
13406
|
+
{ key: 'posts', label: 'Posts', type: 'numeric', align: 'right', formatter: fmt },
|
|
13407
|
+
{ key: 'upvotes', label: 'Upvotes', type: 'numeric', align: 'right', formatter: fmt,
|
|
13408
|
+
helpText: 'Sum of upvotes on Reddit posts created in the selected window, minus the auto-self-upvote per post (matches the Project Funnel Stats column).' },
|
|
13409
|
+
{ key: 'comments', label: 'Comments', type: 'numeric', align: 'right', formatter: fmt },
|
|
13410
|
+
{ key: 'views', label: 'Views', type: 'numeric', align: 'right', formatter: fmt },
|
|
13411
|
+
{ key: 'post_clicks', label: 'Post Clicks', type: 'numeric', align: 'right', formatter: fmt,
|
|
13412
|
+
helpText: 'Clicks on /r/<code> short links minted for posts created in the selected window (no period-total here \u2014 this is a fast Reddit-only path).' },
|
|
13413
|
+
],
|
|
13414
|
+
});
|
|
13415
|
+
}
|
|
13416
|
+
|
|
13207
13417
|
let _dmStatsTableState = { sortField: 'sent', sortDir: 'desc', filters: {} };
|
|
13208
13418
|
function renderDmStats(payload) {
|
|
13209
13419
|
const body = document.getElementById('dm-stats-body');
|
|
@@ -16392,6 +16602,34 @@ async function loadFunnelStats(force) {
|
|
|
16392
16602
|
}
|
|
16393
16603
|
}
|
|
16394
16604
|
|
|
16605
|
+
let _subredditStatsLoadedFor = null;
|
|
16606
|
+
let _subredditStatsLoading = false;
|
|
16607
|
+
async function loadSubredditStats(force) {
|
|
16608
|
+
if (_subredditStatsLoading) return;
|
|
16609
|
+
if (saAuthNotReady()) return;
|
|
16610
|
+
const days = currentStatsWindow().days;
|
|
16611
|
+
const proj = currentStatsProject();
|
|
16612
|
+
const loadKey = days + '|' + proj;
|
|
16613
|
+
if (_subredditStatsLoadedFor === loadKey && !force) return;
|
|
16614
|
+
_subredditStatsLoading = true;
|
|
16615
|
+
const totalEl = document.getElementById('subreddit-stats-total');
|
|
16616
|
+
const body = document.getElementById('subreddit-stats-body');
|
|
16617
|
+
if (totalEl) totalEl.textContent = 'loading\u2026';
|
|
16618
|
+
if (body) body.innerHTML = '<div class="style-stats-empty">Loading\u2026</div>';
|
|
16619
|
+
try {
|
|
16620
|
+
const params = ['days=' + days];
|
|
16621
|
+
if (proj && proj !== 'all') params.push('project=' + encodeURIComponent(proj));
|
|
16622
|
+
const res = await fetch('/api/funnel/stats/by_subreddit?' + params.join('&'));
|
|
16623
|
+
const data = await res.json();
|
|
16624
|
+
renderSubredditStats(data);
|
|
16625
|
+
_subredditStatsLoadedFor = loadKey;
|
|
16626
|
+
} catch (e) {
|
|
16627
|
+
if (body) body.innerHTML = '<div class="style-stats-empty">Failed to load.</div>';
|
|
16628
|
+
} finally {
|
|
16629
|
+
_subredditStatsLoading = false;
|
|
16630
|
+
}
|
|
16631
|
+
}
|
|
16632
|
+
|
|
16395
16633
|
let _dmStatsLoadedFor = null;
|
|
16396
16634
|
let _dmStatsLoading = false;
|
|
16397
16635
|
async function loadDmStats(force) {
|
|
@@ -16707,6 +16945,8 @@ _saInstallDeleteListener();
|
|
|
16707
16945
|
if (funnelEl && funnelEl.open) loadFunnelStats(true);
|
|
16708
16946
|
const dmEl = document.getElementById('dm-stats');
|
|
16709
16947
|
if (dmEl && dmEl.open) loadDmStats(true);
|
|
16948
|
+
const subEl = document.getElementById('subreddit-stats');
|
|
16949
|
+
if (subEl && subEl.open) loadSubredditStats(true);
|
|
16710
16950
|
});
|
|
16711
16951
|
syncStatsHeadings();
|
|
16712
16952
|
})();
|
|
@@ -16817,6 +17057,15 @@ _saInstallDeleteListener();
|
|
|
16817
17057
|
}
|
|
16818
17058
|
})();
|
|
16819
17059
|
|
|
17060
|
+
(function wireSubredditStats() {
|
|
17061
|
+
const el = document.getElementById('subreddit-stats');
|
|
17062
|
+
if (!el) return;
|
|
17063
|
+
el.addEventListener('toggle', () => {
|
|
17064
|
+
try { window.posthog && window.posthog.capture('section_toggle', { section: 'subreddit-stats', open: !!el.open }); } catch (er) {}
|
|
17065
|
+
if (el.open) loadSubredditStats();
|
|
17066
|
+
});
|
|
17067
|
+
})();
|
|
17068
|
+
|
|
16820
17069
|
(function wireCostStats() {
|
|
16821
17070
|
const el = document.getElementById('cost-stats');
|
|
16822
17071
|
if (!el) return;
|
|
@@ -16898,6 +17147,8 @@ function saStartApp() {
|
|
|
16898
17147
|
if (dmEl && dmEl.open) loadDmStats();
|
|
16899
17148
|
const sqEl = document.getElementById('search-queries-stats');
|
|
16900
17149
|
if (sqEl && sqEl.open) loadSearchQueriesStats();
|
|
17150
|
+
const subEl = document.getElementById('subreddit-stats');
|
|
17151
|
+
if (subEl && subEl.open) loadSubredditStats();
|
|
16901
17152
|
setInterval(loadActivityStats, 300000);
|
|
16902
17153
|
setInterval(loadCohortStats, 300000);
|
|
16903
17154
|
setInterval(loadStyleStats, 300000);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"mcpServers": {
|
|
3
|
+
"reddit-agent": {
|
|
4
|
+
"type": "stdio",
|
|
5
|
+
"command": "/usr/bin/python3",
|
|
6
|
+
"args": [
|
|
7
|
+
"__HOME__/social-autoposter/scripts/mcp_lock_proxy.py",
|
|
8
|
+
"--lock-name", "reddit-browser",
|
|
9
|
+
"--ttl", "90",
|
|
10
|
+
"--",
|
|
11
|
+
"npx",
|
|
12
|
+
"@playwright/mcp@latest",
|
|
13
|
+
"--config",
|
|
14
|
+
"__HOME__/.claude/browser-agent-configs/reddit-agent.json"
|
|
15
|
+
],
|
|
16
|
+
"env": {
|
|
17
|
+
"PATH": "__NODE_BIN__:/usr/bin:/bin",
|
|
18
|
+
"BROWSER_LOCK_NAME": "reddit-browser",
|
|
19
|
+
"BROWSER_LOCK_TTL": "90"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"linkedin-agent": {
|
|
23
|
+
"type": "stdio",
|
|
24
|
+
"command": "npx",
|
|
25
|
+
"args": [
|
|
26
|
+
"@playwright/mcp@latest",
|
|
27
|
+
"--config",
|
|
28
|
+
"__HOME__/.claude/browser-agent-configs/linkedin-agent.json"
|
|
29
|
+
],
|
|
30
|
+
"env": {
|
|
31
|
+
"PATH": "__NODE_BIN__:/usr/bin:/bin"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"twitter-harness": {
|
|
35
|
+
"type": "stdio",
|
|
36
|
+
"command": "__UV_BIN__",
|
|
37
|
+
"args": [
|
|
38
|
+
"run",
|
|
39
|
+
"--quiet",
|
|
40
|
+
"--with",
|
|
41
|
+
"mcp",
|
|
42
|
+
"__HOME__/.claude/mcp-servers/browser-harness/server.py"
|
|
43
|
+
],
|
|
44
|
+
"env": {
|
|
45
|
+
"PATH": "__HOME__/.local/bin:__NODE_BIN__:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"mcpServers": {
|
|
3
|
+
"twitter-harness": {
|
|
4
|
+
"type": "stdio",
|
|
5
|
+
"command": "__UV_BIN__",
|
|
6
|
+
"args": [
|
|
7
|
+
"run",
|
|
8
|
+
"--quiet",
|
|
9
|
+
"--with",
|
|
10
|
+
"mcp",
|
|
11
|
+
"__HOME__/.claude/mcp-servers/browser-harness/server.py"
|
|
12
|
+
],
|
|
13
|
+
"env": {
|
|
14
|
+
"PATH": "__HOME__/.local/bin:__NODE_BIN__:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|