social-autoposter 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -18,6 +18,7 @@ const COPY_TARGETS = [
18
18
  'scripts',
19
19
  'schema-postgres.sql',
20
20
  'config.example.json',
21
+ 'requirements.txt',
21
22
  'SKILL.md',
22
23
  'skill',
23
24
  'setup',
@@ -403,14 +404,21 @@ function update() {
403
404
  // browser binary; we run `playwright install chromium` after the pip install.
404
405
  function installPythonDeps() {
405
406
  const reqPath = path.join(PKG_ROOT, 'requirements.txt');
406
- const args = fs.existsSync(reqPath)
407
+ const base = fs.existsSync(reqPath)
407
408
  ? ['install', '-r', reqPath, '-q']
408
409
  : ['install', '-q', 'psycopg2-binary', 'playwright'];
409
410
  console.log(' installing Python deps (psycopg2-binary, playwright, ...)');
410
- const r = spawnSync('pip3', args, { stdio: 'inherit' });
411
+ // Debian/Ubuntu 23+ ship a PEP 668 marker that blocks pip3 against the
412
+ // system Python without --break-system-packages. Try without first
413
+ // (safer on macOS) and retry with the flag if the marker fires.
414
+ let r = spawnSync('pip3', base, { stdio: 'inherit' });
415
+ if (r.status !== 0) {
416
+ console.log(' retrying with --break-system-packages (PEP 668 environments)');
417
+ r = spawnSync('pip3', [...base, '--break-system-packages'], { stdio: 'inherit' });
418
+ }
411
419
  if (r.status !== 0) {
412
420
  console.warn(' WARNING: pip3 install failed — run manually:');
413
- console.warn(` pip3 ${args.join(' ')}`);
421
+ console.warn(` pip3 ${base.join(' ')} --break-system-packages`);
414
422
  return;
415
423
  }
416
424
  // Playwright needs its browser binary downloaded separately. Chromium
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
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"
@@ -399,7 +399,8 @@ def _fetch_style_stats(platform):
399
399
  _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
400
400
  from http_api import api_get
401
401
  resp = api_get("/api/v1/engagement-styles/style-stats", {"platform": platform})
402
- stats = (resp or {}).get("stats") or {}
402
+ data = (resp or {}).get("data") or {}
403
+ stats = data.get("stats") or {}
403
404
  return {
404
405
  name: {"n": int(v.get("n", 0)), "avg_up": float(v.get("avg_up", 0.0))}
405
406
  for name, v in stats.items()
@@ -485,7 +486,8 @@ def _last_picks(platform, limit=10):
485
486
  "/api/v1/engagement-styles/last-picks",
486
487
  {"platform": platform, "limit": int(limit)},
487
488
  )
488
- picks = (resp or {}).get("picks") or []
489
+ data = (resp or {}).get("data") or {}
490
+ picks = data.get("picks") or []
489
491
  return [str(p) for p in picks if p]
490
492
  except Exception:
491
493
  return []
@@ -34,10 +34,6 @@ import time
34
34
 
35
35
 
36
36
  PROFILE_DIR = os.path.expanduser("~/.claude/browser-profiles/twitter")
37
- # Camoufox (Firefox-stealth) parallel profile. Used when TWITTER_ENGINE=camoufox.
38
- # Seeded from Chrome cookies via scripts/dump_twitter_storage.py on first use.
39
- CAMOUFOX_PROFILE_DIR = os.path.expanduser("~/.camoufox-mcp/profiles/twitter")
40
- CAMOUFOX_STORAGE_JSON = os.path.expanduser("~/.camoufox-mcp/storage/twitter.storage.json")
41
37
  LOCK_FILE = os.path.expanduser("~/.claude/twitter-agent-lock.json")
42
38
  LOCK_EXPIRY = 300 # Must match twitter-agent-lock.sh
43
39
  LOCK_WAIT_MAX = 45 # seconds to wait for lock to free before giving up
@@ -270,91 +266,15 @@ def _refresh_browser_lock():
270
266
  pass
271
267
 
272
268
 
273
- def _ensure_camoufox_seeded():
274
- """Make sure ~/.camoufox-mcp/storage/twitter.storage.json exists with valid
275
- cookies. If not, dump them from the live Chrome twitter-agent profile via
276
- scripts/dump_twitter_storage.py. One-shot; cheap if already seeded.
277
- """
278
- if os.path.exists(CAMOUFOX_STORAGE_JSON):
279
- try:
280
- with open(CAMOUFOX_STORAGE_JSON) as f:
281
- data = json.load(f)
282
- has_auth = any(c.get("name") == "auth_token" for c in data.get("cookies", []))
283
- if has_auth:
284
- return
285
- except Exception:
286
- pass
287
- dumper = os.path.join(os.path.dirname(os.path.abspath(__file__)),
288
- "dump_twitter_storage.py")
289
- if os.path.exists(dumper):
290
- subprocess.run([sys.executable, dumper], check=False, timeout=60)
291
-
292
-
293
- def _launch_camoufox_browser_and_page(playwright):
294
- """Camoufox path: Firefox-engine browser with stealth fingerprint patches.
295
-
296
- Headless-safe (camoufox patches Firefox at the C++ layer so headless looks
297
- identical to headed), so it sidesteps Twitter's 2026-04-30 headless-Chrome
298
- DM strip-down. Uses its own persistent profile dir to avoid colliding with
299
- the Chrome twitter-agent's profile lock.
300
-
301
- Returns (context, page, False) to match the existing non-CDP contract.
302
- """
303
- from camoufox.sync_api import NewBrowser
304
- os.makedirs(CAMOUFOX_PROFILE_DIR, exist_ok=True)
305
- _ensure_camoufox_seeded()
306
- headless_env = os.environ.get("TWITTER_CAMOUFOX_HEADLESS", "true").lower()
307
- headless = headless_env not in ("0", "false", "no")
308
- context = NewBrowser(
309
- playwright,
310
- persistent_context=True,
311
- user_data_dir=CAMOUFOX_PROFILE_DIR,
312
- headless=headless,
313
- humanize=True,
314
- block_webrtc=True,
315
- geoip=True,
316
- )
317
- # First-run cookie hydration from the seeded storage JSON (only if the
318
- # persistent profile doesn't already carry an auth_token).
319
- try:
320
- existing = context.cookies()
321
- if not any(c.get("name") == "auth_token" for c in existing):
322
- if os.path.exists(CAMOUFOX_STORAGE_JSON):
323
- with open(CAMOUFOX_STORAGE_JSON) as f:
324
- state = json.load(f)
325
- if state.get("cookies"):
326
- context.add_cookies(state["cookies"])
327
- except Exception as e:
328
- print(f"[twitter_browser] camoufox cookie hydration failed: {e}",
329
- file=sys.stderr)
330
- page = context.new_page()
331
- return context, page, False
332
-
333
-
334
269
  def get_browser_and_page(playwright):
335
270
  """Connect to the twitter-agent MCP browser via CDP, or launch a new one.
336
271
 
337
272
  Returns (browser, page, is_cdp). When is_cdp=True, `page` is a reused
338
273
  existing Twitter tab (navigate it, don't close it). When is_cdp=False,
339
274
  it's a new headless page.
340
-
341
- Set TWITTER_ENGINE=camoufox to swap in a Firefox-stealth engine instead of
342
- Chrome. Same return shape; all callers untouched. See
343
- _launch_camoufox_browser_and_page() above.
344
275
  """
345
276
  _acquire_browser_lock()
346
277
 
347
- if os.environ.get("TWITTER_ENGINE", "").lower() == "camoufox":
348
- try:
349
- return _launch_camoufox_browser_and_page(playwright)
350
- except Exception as e:
351
- _release_browser_lock()
352
- print(json.dumps({
353
- "success": False,
354
- "error": f"camoufox launch failed: {e}"
355
- }))
356
- sys.exit(1)
357
-
358
278
  cdp_port = find_twitter_cdp_port()
359
279
 
360
280
  if cdp_port:
@@ -583,19 +503,14 @@ def reply_to_tweet(tweet_url, text, apply_campaigns=True):
583
503
 
584
504
  try:
585
505
  # Set up Network interception to capture CreateTweet response.
586
- # Two parallel paths so both engines work:
587
- # (a) page.on("response") — engine-agnostic. Works on Chromium
588
- # AND on Firefox/camoufox. This is the only path that fires
589
- # when TWITTER_ENGINE=camoufox (Firefox doesn't support CDP).
590
- # (b) CDP Network.responseReceived — Chromium-only. Slightly
591
- # faster + less body-fetch overhead. Skipped on Firefox
592
- # because new_cdp_session raises "CDP session is only
593
- # available in Chromium" there.
506
+ # Two parallel paths for redundancy:
507
+ # (a) page.on("response") — Playwright's event-loop hook.
508
+ # (b) CDP Network.responseReceived slightly faster + less
509
+ # body-fetch overhead, Chromium-only.
594
510
  # Both write into _created_tweet_ids; dedup-on-append keeps the
595
511
  # list a set of unique rest_ids regardless of which path fired.
596
512
  _cdp_session = None
597
513
  _created_tweet_ids = []
598
- _is_firefox = os.environ.get("TWITTER_ENGINE", "").lower() == "camoufox"
599
514
 
600
515
  def _on_response_event(resp):
601
516
  # Engine-agnostic CreateTweet capture. Filter by URL FIRST so
@@ -620,35 +535,34 @@ def reply_to_tweet(tweet_url, text, apply_campaigns=True):
620
535
 
621
536
  page.on("response", _on_response_event)
622
537
 
623
- if not _is_firefox:
624
- try:
625
- _cdp_session = page.context.new_cdp_session(page)
626
- _cdp_session.send("Network.enable")
627
-
628
- def _on_cdp_response(params):
629
- try:
630
- url = params.get("response", {}).get("url", "")
631
- if "CreateTweet" in url:
632
- body_resp = _cdp_session.send(
633
- "Network.getResponseBody",
634
- {"requestId": params["requestId"]},
635
- )
636
- data = json.loads(body_resp.get("body", "{}"))
637
- rest_id = (
638
- data.get("data", {})
639
- .get("create_tweet", {})
640
- .get("tweet_results", {})
641
- .get("result", {})
642
- .get("rest_id")
643
- )
644
- if rest_id and rest_id not in _created_tweet_ids:
645
- _created_tweet_ids.append(rest_id)
646
- except Exception:
647
- pass
538
+ try:
539
+ _cdp_session = page.context.new_cdp_session(page)
540
+ _cdp_session.send("Network.enable")
648
541
 
649
- _cdp_session.on("Network.responseReceived", _on_cdp_response)
650
- except Exception:
651
- pass
542
+ def _on_cdp_response(params):
543
+ try:
544
+ url = params.get("response", {}).get("url", "")
545
+ if "CreateTweet" in url:
546
+ body_resp = _cdp_session.send(
547
+ "Network.getResponseBody",
548
+ {"requestId": params["requestId"]},
549
+ )
550
+ data = json.loads(body_resp.get("body", "{}"))
551
+ rest_id = (
552
+ data.get("data", {})
553
+ .get("create_tweet", {})
554
+ .get("tweet_results", {})
555
+ .get("result", {})
556
+ .get("rest_id")
557
+ )
558
+ if rest_id and rest_id not in _created_tweet_ids:
559
+ _created_tweet_ids.append(rest_id)
560
+ except Exception:
561
+ pass
562
+
563
+ _cdp_session.on("Network.responseReceived", _on_cdp_response)
564
+ except Exception:
565
+ pass
652
566
 
653
567
  page.goto(tweet_url, wait_until="domcontentloaded")
654
568
  page.wait_for_timeout(5000)
@@ -718,8 +632,7 @@ def reply_to_tweet(tweet_url, text, apply_campaigns=True):
718
632
  # Method 1: CDP network interception (most reliable)
719
633
  if _created_tweet_ids:
720
634
  reply_url = f"https://x.com/{OUR_HANDLE}/status/{_created_tweet_ids[-1]}"
721
- src = "response-listener" if _is_firefox else "CDP+response-listener"
722
- print(f"[reply_url] captured via {src}: {reply_url}", file=sys.stderr)
635
+ print(f"[reply_url] captured via CDP+response-listener: {reply_url}", file=sys.stderr)
723
636
 
724
637
  # Method 2: DOM diff (check if new reply links appeared)
725
638
  if not reply_url:
@@ -48,47 +48,18 @@ QUERIES_FILE="/tmp/twitter_cycle_queries_$(date +%s).json"
48
48
  RUN_START=$(date +%s)
49
49
 
50
50
  # ----------------------------------------------------------------------------
51
- # Engine switch: TWITTER_ENGINE controls which browser the Claude SCAN/PREP
52
- # phases drive AND which engine the python posting path (twitter_browser.py)
53
- # uses. Defaults to chrome so unset env = today's behavior, zero drift.
51
+ # Browser: Playwright MCP attached to Chrome twitter-agent profile at
52
+ # ~/.claude/browser-profiles/twitter. (Camoufox/Firefox engine was carved out
53
+ # 2026-05-13; only Chrome is supported now.)
54
54
  #
55
- # TWITTER_ENGINE=chrome (default) Playwright MCP -> Chrome twitter-agent
56
- # profile at ~/.claude/browser-profiles/twitter
57
- # TWITTER_ENGINE=camoufox camoufox MCP -> Firefox-stealth profile at
58
- # ~/.camoufox-mcp/profiles/twitter
59
- #
60
- # All Claude SDK invocations in this script use $TW_MCP_CONFIG so the swap is
61
- # one variable. Both prompt strings get $TW_ENGINE_PREFIX prepended; for the
62
- # chrome path the prefix is empty (no behavior change). For camoufox the
63
- # prefix tells Claude to call camoufox_open(name="twitter") first to attach
64
- # to the already-logged-in session — the MCP is session-based with no
65
- # implicit default.
55
+ # Vars are kept (TW_MCP_CONFIG, TW_BROWSER_PROFILE, TW_ENGINE_PREFIX) so the
56
+ # downstream Claude SDK calls and singleton-cleanup hooks need no edits.
57
+ # TW_ENGINE_PREFIX is empty by design; it used to prepend engine-specific
58
+ # instructions to the scan/prep prompts.
66
59
  # ----------------------------------------------------------------------------
67
- TWITTER_ENGINE="${TWITTER_ENGINE:-chrome}"
68
- if [ "$TWITTER_ENGINE" = "camoufox" ]; then
69
- TW_MCP_CONFIG="$HOME/.claude/browser-agent-configs/twitter-agent-camoufox-mcp.json"
70
- TW_BROWSER_PROFILE="$HOME/.camoufox-mcp/profiles/twitter"
71
- # Export so python subprocesses (twitter_browser.py via twitter_post_plan.py
72
- # and any other indirect callers) opt into camoufox via os.environ check.
73
- export TWITTER_ENGINE
74
- export TWITTER_CAMOUFOX_HEADLESS="${TWITTER_CAMOUFOX_HEADLESS:-true}"
75
- # Headless flag for the Claude SDK scan/prep prompts. The camoufox MCP's
76
- # camoufox_open() takes `headless` as a tool argument, NOT an env var, so
77
- # the prefix must explicitly tell Claude to pass it. Default headless=True;
78
- # set TWITTER_CAMOUFOX_HEADLESS=false to debug visually.
79
- if [ "${TWITTER_CAMOUFOX_HEADLESS}" = "true" ]; then
80
- TW_OPEN_CALL='camoufox_open(name="twitter", headless=True)'
81
- else
82
- TW_OPEN_CALL='camoufox_open(name="twitter")'
83
- fi
84
- TW_ENGINE_PREFIX="ENGINE: camoufox (Firefox-stealth via camoufox MCP). Before doing anything else, call ${TW_OPEN_CALL} to attach to the already-logged-in twitter session at ~/.camoufox-mcp/profiles/twitter. Use camoufox_navigate / camoufox_evaluate / camoufox_snapshot / camoufox_click / camoufox_type — all require name=\"twitter\". Do NOT use any browser_* tool; they are not available on this engine.
85
-
86
- "
87
- else
88
- TW_MCP_CONFIG="$HOME/.claude/browser-agent-configs/twitter-agent-mcp.json"
89
- TW_BROWSER_PROFILE="$HOME/.claude/browser-profiles/twitter"
90
- TW_ENGINE_PREFIX=""
91
- fi
60
+ TW_MCP_CONFIG="$HOME/.claude/browser-agent-configs/twitter-agent-mcp.json"
61
+ TW_BROWSER_PROFILE="$HOME/.claude/browser-profiles/twitter"
62
+ TW_ENGINE_PREFIX=""
92
63
  # Tweets older than this are no longer worth replying to. Pending rows older
93
64
  # than this are hard-expired by Phase 0; younger pending rows are salvaged
94
65
  # from prior cycles into this batch.
@@ -1,25 +0,0 @@
1
- """One-shot: launch Playwright against twitter-agent profile, dump storage_state.json, close."""
2
- import os, sys, json
3
- from pathlib import Path
4
- from playwright.sync_api import sync_playwright
5
-
6
- PROFILE = str(Path.home() / ".claude/browser-profiles/twitter")
7
- OUT = str(Path.home() / ".camoufox-mcp/storage/twitter.storage.json")
8
- Path(OUT).parent.mkdir(parents=True, exist_ok=True)
9
-
10
- with sync_playwright() as p:
11
- ctx = p.chromium.launch_persistent_context(
12
- PROFILE,
13
- headless=True,
14
- args=["--no-sandbox"],
15
- )
16
- state = ctx.storage_state(path=OUT)
17
- cookies = state.get("cookies", [])
18
- tw = [c for c in cookies if any(d in c.get("domain","") for d in ("twitter.com","x.com"))]
19
- print(f"total cookies: {len(cookies)}, twitter/x: {len(tw)}")
20
- if tw:
21
- names = sorted({c['name'] for c in tw})
22
- print("twitter cookie names:", names[:20])
23
- ctx.close()
24
-
25
- print(f"wrote {OUT}")