social-autoposter 1.3.8 → 1.3.10

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/server.js CHANGED
@@ -2454,6 +2454,12 @@ const bookingsPerDayCache = new Map();
2454
2454
  // post_link_clicks (per-hit log) filtered to is_bot=false so we count humans
2455
2455
  // only; joins to post_links + posts so platform/project filters apply.
2456
2456
  const clicksPerDayCache = new Map();
2457
+ // Posts-per-day: cached by days|platform|project. Backs the Trends-tab
2458
+ // "Views / Post" ratio denominator. Counts posts.posted_at grouped by UTC date,
2459
+ // excluding moltbook/github/github_issues to match the views/upvotes/comments
2460
+ // scope (those platforms don't surface views, so including them would
2461
+ // understate views-per-post for the comparable platforms).
2462
+ const postsPerDayCache = new Map();
2457
2463
  // Funnel-per-day (PostHog-backed metrics): cached by days.
2458
2464
  const funnelPerDayCache = new Map();
2459
2465
  // Cost-per-day: Claude API session cost attributed to days an activity row
@@ -4317,6 +4323,56 @@ async function handleApi(req, res) {
4317
4323
  })().catch(e => json(res, { error: e.message }, 500));
4318
4324
  }
4319
4325
 
4326
+ // GET /api/posts/per-day?days=N&platform=X&project=Y - count of posts we
4327
+ // made per day, sourced from posts.posted_at. Same platform/project filter
4328
+ // shape as views/upvotes/comments so Trends-tab filters behave identically.
4329
+ // Excludes moltbook/github/github_issues to match the views denominator
4330
+ // scope (those platforms don't surface views, so including their posts
4331
+ // would understate the views-per-post ratio). Backs the Trends-tab
4332
+ // "Views / Post" pill.
4333
+ if (p === '/api/posts/per-day' && req.method === 'GET') {
4334
+ if (!req.user.admin) return json(res, { error: 'forbidden' }, 403);
4335
+ const url = new URL(req.url, 'http://localhost');
4336
+ const days = Math.max(1, Math.min(365, parseInt(url.searchParams.get('days') || '30', 10) || 30));
4337
+ const rawPlatform = (url.searchParams.get('platform') || '').trim().toLowerCase();
4338
+ const platform = (rawPlatform === '' || rawPlatform === 'all') ? '' :
4339
+ (rawPlatform === 'x' ? 'twitter' : rawPlatform);
4340
+ const platformOk = platform === '' || /^[a-z0-9_]{1,32}$/.test(platform);
4341
+ if (!platformOk) return json(res, { error: 'invalid platform' }, 400);
4342
+ const rawProject = (url.searchParams.get('project') || '').trim();
4343
+ const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
4344
+ const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
4345
+ if (!projectOk) return json(res, { error: 'invalid project' }, 400);
4346
+ const cacheKey = days + '|' + platform + '|' + project;
4347
+ const cached = postsPerDayCache.get(cacheKey);
4348
+ if (cached && Date.now() - cached.at < 300000) {
4349
+ return json(res, { days, rows: cached.value, cachedAt: cached.at });
4350
+ }
4351
+ const platformFilter = platform
4352
+ ? " AND CASE WHEN LOWER(p.platform) = 'x' THEN 'twitter' ELSE LOWER(p.platform) END = '" + platform + "'"
4353
+ : '';
4354
+ const projectFilter = project
4355
+ ? " AND p.project_name = '" + project.replace(/'/g, "''") + "'"
4356
+ : '';
4357
+ const q =
4358
+ "SELECT json_agg(row_to_json(r)) FROM (" +
4359
+ "SELECT to_char((p.posted_at AT TIME ZONE 'UTC')::date, 'YYYY-MM-DD') AS day, " +
4360
+ "COUNT(*)::bigint AS posts_made " +
4361
+ "FROM posts p " +
4362
+ "WHERE p.posted_at IS NOT NULL " +
4363
+ "AND p.posted_at >= CURRENT_DATE - INTERVAL '" + days + " days' " +
4364
+ "AND LOWER(p.platform) NOT IN ('moltbook', 'github', 'github_issues')" +
4365
+ platformFilter + projectFilter + " " +
4366
+ "GROUP BY day ORDER BY day ASC" +
4367
+ ") r";
4368
+ return (async () => {
4369
+ const rows = await pq(q);
4370
+ const value = (rows && rows.length && rows[0].json_agg) ? rows[0].json_agg : [];
4371
+ postsPerDayCache.set(cacheKey, { at: Date.now(), value });
4372
+ return json(res, { days, rows: value });
4373
+ })().catch(e => json(res, { error: e.message }, 500));
4374
+ }
4375
+
4320
4376
  // GET /api/bookings/per-day?days=N - real Cal.com bookings per day from
4321
4377
  // the separate BOOKINGS_DATABASE_URL Neon DB. Filters out test bookings
4322
4378
  // the same way project_stats_json.py does (attendee_email NOT ILIKE
@@ -9989,6 +10045,11 @@ function renderDailyMetrics() {
9989
10045
  // no new fetch needed. Values are percentages (0-100), formatted to one
9990
10046
  // decimal place; days with views=0 are dropped (ratios are undefined).
9991
10047
  let RATIO_METRICS = [
10048
+ // Views per post: how many views a post earns on average per day in the
10049
+ // window. Numerator is views_gained that day; denominator is posts_made
10050
+ // that day. format='count' renders as plain K/M numbers (e.g. "1.2K")
10051
+ // since the value is a count, not a percentage or dollar figure.
10052
+ { id: 'views_per_post', label: 'Views / Post', color: '#a855f7', numerator: 'views', denominator: 'posts', format: 'count', scaleFactor: 1 },
9992
10053
  { id: 'upvotes_per_view', label: 'Upvotes / Views', color: '#f97316', numerator: 'upvotes', denominator: 'views', format: 'pct', scaleFactor: 100 },
9993
10054
  { id: 'comments_per_view', label: 'Comments / Views', color: '#14b8a6', numerator: 'comments', denominator: 'views', format: 'pct', scaleFactor: 100 },
9994
10055
  { id: 'clicks_per_view', label: 'Clicks / Views', color: '#0ea5e9', numerator: 'clicks', denominator: 'views', format: 'pct', scaleFactor: 100 },
@@ -10007,11 +10068,11 @@ let RATIO_METRICS = [
10007
10068
  { id: 'cost_per_kviews', label: 'Cost / 1k Views', color: '#dc2626', numerator: 'cost', denominator: 'views', format: 'usd', scaleFactor: 1000, adminOnly: true },
10008
10069
  { id: 'cost_per_kvisitors', label: 'Cost / 1k Visitors', color: '#7c3aed', numerator: 'cost', denominator: 'pageviews', format: 'usd', scaleFactor: 1000, adminOnly: true },
10009
10070
  ];
10010
- const RATIO_METRICS_DEFAULTS = ['upvotes_per_view', 'comments_per_view', 'clicks_per_view', 'email_signups_per_session', 'schedule_clicks_per_session', 'get_started_per_session', 'cost_per_kviews', 'cost_per_kvisitors'];
10011
- // .v2: ratio set expanded to include cost_per_kviews + cost_per_kvisitors.
10012
- // Bumping the storage key seeds the new defaults exactly once so existing
10013
- // users see the new ratios pre-selected the next time they open Trends.
10014
- const RATIO_METRICS_STORAGE_KEY = 'ratioMetricsActive.v2';
10071
+ const RATIO_METRICS_DEFAULTS = ['views_per_post', 'upvotes_per_view', 'comments_per_view', 'clicks_per_view', 'email_signups_per_session', 'schedule_clicks_per_session', 'get_started_per_session', 'cost_per_kviews', 'cost_per_kvisitors'];
10072
+ // .v3: ratio set expanded to include views_per_post at the head. Bumping
10073
+ // the storage key seeds the new defaults exactly once so existing users
10074
+ // see the new ratio pre-selected the next time they open Trends.
10075
+ const RATIO_METRICS_STORAGE_KEY = 'ratioMetricsActive.v3';
10015
10076
  let _ratioMetricsActive = null;
10016
10077
  function _loadRatioMetricsActive() {
10017
10078
  if (_ratioMetricsActive) return _ratioMetricsActive;
@@ -10036,6 +10097,12 @@ function _fmtPct(n) {
10036
10097
  function _fmtForRatio(r, n) {
10037
10098
  if (n == null || !isFinite(n)) return '—';
10038
10099
  if (r && r.format === 'usd') return _fmtUsd(n);
10100
+ if (r && r.format === 'count') {
10101
+ // Whole-number K/M counts (no decimals) for ratios like Views / Post.
10102
+ if (n >= 1_000_000) return Math.round(n / 1_000_000) + 'M';
10103
+ if (n >= 1_000) return Math.round(n / 1_000) + 'K';
10104
+ return String(Math.round(n));
10105
+ }
10039
10106
  return _fmtPct(n);
10040
10107
  }
10041
10108
 
@@ -10203,10 +10270,13 @@ function renderRatioMetrics() {
10203
10270
  const day = days[idx];
10204
10271
  const rows = visible.map(r => {
10205
10272
  const v = ratioSeries[r.id][day];
10206
- // "no views" / "no visitors" depending on the denominator. Cost
10207
- // ratios use the right unit so the empty-day message matches the
10208
- // ratio's meaning ("no views" for cost_per_kviews, etc.).
10209
- const emptyLabel = (r.denominator === 'pageviews') ? 'no visitors' : 'no views';
10273
+ // "no views" / "no visitors" / "no posts" depending on the
10274
+ // denominator. Cost ratios use the right unit so the empty-day
10275
+ // message matches the ratio's meaning ("no views" for
10276
+ // cost_per_kviews, etc.).
10277
+ const emptyLabel = (r.denominator === 'pageviews') ? 'no visitors'
10278
+ : (r.denominator === 'posts') ? 'no posts'
10279
+ : 'no views';
10210
10280
  const display = (v == null || !isFinite(v)) ? emptyLabel : _fmtForRatio(r, v);
10211
10281
  return '<div class="tt-row"><span class="swatch" style="background:' + r.color + ';"></span>' +
10212
10282
  '<span>' + escapeHtml(r.label) + '</span>' +
@@ -10355,7 +10425,7 @@ async function loadDailyMetrics() {
10355
10425
  const qsProj = projectOnlyParams.join('&');
10356
10426
  try {
10357
10427
  const costAvail = window.SA_IS_ADMIN !== false;
10358
- const [views, upvotes, comments, clicks, bookings, funnel, cost] = await Promise.all([
10428
+ const [views, upvotes, comments, clicks, bookings, funnel, cost, posts] = await Promise.all([
10359
10429
  fetchOne('/api/views/per-day?' + qsAware),
10360
10430
  fetchOne('/api/upvotes/per-day?' + qsAware),
10361
10431
  fetchOne('/api/comments/per-day?' + qsAware),
@@ -10363,8 +10433,9 @@ async function loadDailyMetrics() {
10363
10433
  fetchOne('/api/bookings/per-day?' + qsProj),
10364
10434
  fetchOne('/api/funnel/per-day?' + qsProj),
10365
10435
  costAvail ? fetchOne('/api/cost/per-day?' + qsAware) : Promise.resolve({ rows: [], failed: false }),
10436
+ fetchOne('/api/posts/per-day?' + qsAware),
10366
10437
  ]);
10367
- const allFailed = [views, upvotes, comments, clicks, bookings, funnel, cost].every(r => r.failed);
10438
+ const allFailed = [views, upvotes, comments, clicks, bookings, funnel, cost, posts].every(r => r.failed);
10368
10439
  if (allFailed) {
10369
10440
  if (chartEl) chartEl.innerHTML = '<div class="views-chart-empty">Unable to load daily metrics (all endpoints failed).</div>';
10370
10441
  return;
@@ -10380,6 +10451,7 @@ async function loadDailyMetrics() {
10380
10451
  intoSeries('clicks', clicks.rows, 'clicks_gained');
10381
10452
  intoSeries('bookings', bookings.rows, 'bookings_gained');
10382
10453
  intoSeries('cost', cost.rows, 'cost_usd');
10454
+ intoSeries('posts', posts.rows, 'posts_made');
10383
10455
  DAILY_METRICS.filter(m => m.funnel).forEach(m => {
10384
10456
  intoSeries(m.id, funnel.rows, m.valueKey);
10385
10457
  });
@@ -10402,7 +10474,7 @@ async function loadDailyMetrics() {
10402
10474
  // Stash a list of failed endpoints so renderDailyMetrics can surface a
10403
10475
  // small "(N timed out)" hint in the status pill rather than silently
10404
10476
  // showing flat zeros for those series.
10405
- const fetchResults = { views, upvotes, comments, clicks, bookings, funnel, cost };
10477
+ const fetchResults = { views, upvotes, comments, clicks, bookings, funnel, cost, posts };
10406
10478
  _dailyMetricsFailed = Object.keys(fetchResults)
10407
10479
  .filter(k => fetchResults[k].failed)
10408
10480
  .map(k => ({ key: k, timedOut: !!fetchResults[k].timedOut }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.3.8",
3
+ "version": "1.3.10",
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"
@@ -61,14 +61,23 @@ def _diag_log(msg):
61
61
  pass
62
62
  VIEWPORT = {"width": 911, "height": 1016}
63
63
 
64
- # Load Reddit username from config
64
+ # Load Reddit username from config.
65
+ # Prefers the new top-level `reddit_account.username` (2026-05-15) over the
66
+ # legacy `accounts.reddit.username` path. Drift between the two silently
67
+ # broke the post-permalink lookup on the VM (wrong username → JS finds 0
68
+ # matching comments → permalink=None → pipeline records `failed` despite
69
+ # the comment landing on Reddit).
65
70
  _config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "config.json")
66
71
  OUR_USERNAME = "Deep_Ad1959"
67
72
  if os.path.exists(_config_path):
68
73
  try:
69
74
  with open(_config_path) as f:
70
75
  _cfg = json.load(f)
71
- OUR_USERNAME = _cfg.get("accounts", {}).get("reddit", {}).get("username", OUR_USERNAME)
76
+ OUR_USERNAME = (
77
+ (_cfg.get("reddit_account") or {}).get("username")
78
+ or _cfg.get("accounts", {}).get("reddit", {}).get("username")
79
+ or OUR_USERNAME
80
+ )
72
81
  except Exception:
73
82
  pass
74
83
 
@@ -295,22 +304,52 @@ def _refresh_browser_lock():
295
304
 
296
305
 
297
306
  def get_browser_and_page(playwright):
298
- """Connect to the reddit-agent MCP browser via CDP with a fresh logged-in context.
299
-
300
- Creates a NEW browser context with storageState cookies (the logged-in session)
301
- rather than reusing contexts[0] (the default context, which is NOT logged in).
302
- The MCP's isolated context is invisible to CDP connections, so we must create
303
- our own context with the same storageState.
304
-
305
- Returns (browser, page, is_cdp). When is_cdp=True, `page` is in a new context
306
- on the CDP browser. When is_cdp=False, it's a new headless page.
307
+ """Get a logged-in Reddit page, preferring CDP-attach over launch_persistent_context.
308
+
309
+ Two paths:
310
+ 1. CDP-attach (preferred on appmaker/e2b VM and any host running a visible
311
+ logged-in Chromium): connect to the existing browser, find a context with
312
+ a live reddit_session cookie, open a NEW PAGE on that context.
313
+ 2. launch_persistent_context fallback: when CDP isn't available OR contexts
314
+ have no reddit_session (laptop where reddit-agent MCP isolates its session
315
+ in an invisible context).
316
+
317
+ Why CDP-attach matters: appmaker's visible Chromium permanently holds
318
+ /root/.chromium-profile. launch_persistent_context collides on profile leveldb
319
+ locks, loads a partial session, and EVERY post returns account_blocked_in_sub
320
+ because the comment form never renders. Attaching to the live context dodges
321
+ the collision entirely.
322
+
323
+ Returns (browser, page, is_cdp). When is_cdp=True, callers must close ONLY
324
+ the page (not page.context) and NOT the browser; closing context[0] or the
325
+ CDP browser would kill the user's visible session.
307
326
  """
308
327
  _acquire_browser_lock()
309
328
  cdp_port = find_reddit_cdp_port()
310
329
 
311
- # Always use the persistent profile directly. CDP connections to the MCP
312
- # browser expose a default context that is NOT logged in (the MCP's logged-in
313
- # context is isolated/invisible to CDP), causing auth failures.
330
+ if cdp_port:
331
+ try:
332
+ cdp_browser = playwright.chromium.connect_over_cdp(f"http://localhost:{cdp_port}")
333
+ for ctx in cdp_browser.contexts:
334
+ try:
335
+ cookies = ctx.cookies("https://www.reddit.com/")
336
+ except Exception:
337
+ cookies = []
338
+ has_session = any(
339
+ c.get("name") == "reddit_session" and c.get("value")
340
+ for c in cookies
341
+ )
342
+ if has_session:
343
+ page = ctx.new_page()
344
+ return cdp_browser, page, True
345
+ try:
346
+ cdp_browser.close()
347
+ except Exception:
348
+ pass
349
+ except Exception:
350
+ pass
351
+
352
+ # Fallback: launch our own persistent context against PROFILE_DIR.
314
353
  # Retry on Chromium SingletonLock collisions (MCP holds the OS-level profile
315
354
  # lock for its entire server lifetime; the JSON lock can expire while the
316
355
  # OS lock is still held).
@@ -496,9 +535,18 @@ def post_comment(thread_url, text):
496
535
  }
497
536
 
498
537
  finally:
499
- page.context.close()
538
+ try:
539
+ if is_cdp:
540
+ page.close()
541
+ else:
542
+ page.context.close()
543
+ except Exception:
544
+ pass
500
545
  if not is_cdp:
501
- browser.close()
546
+ try:
547
+ browser.close()
548
+ except Exception:
549
+ pass
502
550
 
503
551
 
504
552
  def reply_to_comment(comment_permalink, text, dm_id=None):
@@ -736,9 +784,18 @@ def reply_to_comment(comment_permalink, text, dm_id=None):
736
784
  }
737
785
 
738
786
  finally:
739
- page.context.close()
787
+ try:
788
+ if is_cdp:
789
+ page.close()
790
+ else:
791
+ page.context.close()
792
+ except Exception:
793
+ pass
740
794
  if not is_cdp:
741
- browser.close()
795
+ try:
796
+ browser.close()
797
+ except Exception:
798
+ pass
742
799
 
743
800
 
744
801
  def edit_comment(comment_permalink, new_text):
@@ -859,9 +916,18 @@ def edit_comment(comment_permalink, new_text):
859
916
  }
860
917
 
861
918
  finally:
862
- page.context.close()
919
+ try:
920
+ if is_cdp:
921
+ page.close()
922
+ else:
923
+ page.context.close()
924
+ except Exception:
925
+ pass
863
926
  if not is_cdp:
864
- browser.close()
927
+ try:
928
+ browser.close()
929
+ except Exception:
930
+ pass
865
931
 
866
932
 
867
933
  def edit_thread(thread_permalink, new_body):
@@ -956,9 +1022,18 @@ def edit_thread(thread_permalink, new_body):
956
1022
  }
957
1023
 
958
1024
  finally:
959
- page.context.close()
1025
+ try:
1026
+ if is_cdp:
1027
+ page.close()
1028
+ else:
1029
+ page.context.close()
1030
+ except Exception:
1031
+ pass
960
1032
  if not is_cdp:
961
- browser.close()
1033
+ try:
1034
+ browser.close()
1035
+ except Exception:
1036
+ pass
962
1037
 
963
1038
 
964
1039
  def unread_dms():
@@ -1135,9 +1210,18 @@ def unread_dms():
1135
1210
  return unique
1136
1211
 
1137
1212
  finally:
1138
- page.context.close()
1213
+ try:
1214
+ if is_cdp:
1215
+ page.close()
1216
+ else:
1217
+ page.context.close()
1218
+ except Exception:
1219
+ pass
1139
1220
  if not is_cdp:
1140
- browser.close()
1221
+ try:
1222
+ browser.close()
1223
+ except Exception:
1224
+ pass
1141
1225
 
1142
1226
 
1143
1227
  def read_conversation(chat_url, max_messages=20):
@@ -1293,9 +1377,18 @@ def read_conversation(chat_url, max_messages=20):
1293
1377
  return result
1294
1378
 
1295
1379
  finally:
1296
- page.context.close()
1380
+ try:
1381
+ if is_cdp:
1382
+ page.close()
1383
+ else:
1384
+ page.context.close()
1385
+ except Exception:
1386
+ pass
1297
1387
  if not is_cdp:
1298
- browser.close()
1388
+ try:
1389
+ browser.close()
1390
+ except Exception:
1391
+ pass
1299
1392
 
1300
1393
 
1301
1394
  def _load_active_reddit_campaigns_for_dm():
@@ -1564,9 +1657,18 @@ def send_dm(chat_url, message, dm_id=None):
1564
1657
  }
1565
1658
 
1566
1659
  finally:
1567
- page.context.close()
1660
+ try:
1661
+ if is_cdp:
1662
+ page.close()
1663
+ else:
1664
+ page.context.close()
1665
+ except Exception:
1666
+ pass
1568
1667
  if not is_cdp:
1569
- browser.close()
1668
+ try:
1669
+ browser.close()
1670
+ except Exception:
1671
+ pass
1570
1672
 
1571
1673
 
1572
1674
  def compose_dm(recipient, subject, body):
@@ -1859,9 +1961,18 @@ def compose_dm(recipient, subject, body):
1859
1961
  return {"ok": True, "thread_url": page.url}
1860
1962
 
1861
1963
  finally:
1862
- page.context.close()
1964
+ try:
1965
+ if is_cdp:
1966
+ page.close()
1967
+ else:
1968
+ page.context.close()
1969
+ except Exception:
1970
+ pass
1863
1971
  if not is_cdp:
1864
- browser.close()
1972
+ try:
1973
+ browser.close()
1974
+ except Exception:
1975
+ pass
1865
1976
 
1866
1977
 
1867
1978
  def scrape_views(username, max_scrolls=300):
@@ -2022,9 +2133,18 @@ def scrape_views(username, max_scrolls=300):
2022
2133
  except Exception as e:
2023
2134
  return {"ok": False, "error": str(e)}
2024
2135
  finally:
2025
- page.context.close()
2136
+ try:
2137
+ if is_cdp:
2138
+ page.close()
2139
+ else:
2140
+ page.context.close()
2141
+ except Exception:
2142
+ pass
2026
2143
  if not is_cdp:
2027
- browser.close()
2144
+ try:
2145
+ browser.close()
2146
+ except Exception:
2147
+ pass
2028
2148
 
2029
2149
 
2030
2150
  def main():