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 +11 -3
- package/package.json +1 -1
- package/scripts/engagement_styles.py +4 -2
- package/scripts/twitter_browser.py +32 -119
- package/skill/run-twitter-cycle.sh +10 -39
- package/scripts/dump_twitter_storage.py +0 -25
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
|
|
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
|
-
|
|
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 ${
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
587
|
-
# (a) page.on("response") —
|
|
588
|
-
#
|
|
589
|
-
#
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
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
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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}")
|