social-autoposter 1.6.26 → 1.6.28
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/README.md +9 -11
- package/bin/cli.js +61 -13
- package/mcp/dist/repo.js +2 -2
- package/mcp/install.mjs +1 -1
- package/mcp/manifest.json +1 -1
- package/mcp-servers/browser-harness/__pycache__/server.cpython-311.pyc +0 -0
- package/mcp-servers/browser-harness/server.py +62 -3
- package/package.json +11 -4
- package/requirements.txt +0 -1
- package/scripts/db.py +33 -180
- package/skill/dm-outreach-twitter.sh +5 -4
- package/skill/run-twitter-cycle.sh +7 -0
- package/bin/auth.js +0 -110
- package/bin/server.js +0 -20840
- package/schema-postgres.sql +0 -495
- package/scripts/_dm_record_sent.sh +0 -8
- package/scripts/mint_podlog_subpage_10k_topup.py +0 -135
- package/scripts/mint_podlog_subpage_500.py +0 -130
- package/scripts/send_batch_dms.sh +0 -51
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Open-source repo behind **[S4L (s4lai)](https://s4l.ai)**: an automated social p
|
|
|
4
4
|
|
|
5
5
|
> The hosted managed version is **S4L** (written `s4lai`, domain `s4l.ai`): done-for-you Reddit and Twitter brand-awareness, $1/1K impressions, $50/1K site visits. See https://s4l.ai.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
State (posts, replies, candidates, stats) is read and written through the hosted S4L HTTP API (`AUTOPOSTER_API_BASE` + an install key in `~/social-autoposter/.env`); no database to provision. Each platform drives its own persistent Playwright MCP browser profile, so logins survive across runs.
|
|
8
8
|
|
|
9
9
|
## Prerequisites
|
|
10
10
|
|
|
@@ -12,9 +12,8 @@ A new machine needs all of these before the pipeline can run end to end:
|
|
|
12
12
|
|
|
13
13
|
- **macOS** (the launchd plists are mac-only; Linux users can crib the cron snippets from `setup/SKILL.md` Step 7)
|
|
14
14
|
- **Node.js 16+** (for `npx`, the installer, and `@playwright/mcp` at runtime)
|
|
15
|
-
- **Python 3.9+** with `pip3` (helper scripts;
|
|
15
|
+
- **Python 3.9+** with `pip3` (helper scripts; deps auto-installed by the installer)
|
|
16
16
|
- **Claude Code CLI** on `PATH` (the cron scripts shell out to `claude -p` with a per-platform MCP config)
|
|
17
|
-
- **`psql`** on `PATH` (a few scripts query Postgres directly)
|
|
18
17
|
- One Chromium install per platform (created on first run by `@playwright/mcp` against the persistent profile dirs)
|
|
19
18
|
|
|
20
19
|
Optional:
|
|
@@ -30,9 +29,9 @@ npx social-autoposter init
|
|
|
30
29
|
|
|
31
30
|
`bin/cli.js` does all of the wiring in one shot:
|
|
32
31
|
|
|
33
|
-
1. Copies `scripts/`, `skill/`, `setup/`, `SKILL.md`,
|
|
34
|
-
2. Creates `config.json` from `config.example.json` and writes a blank `.env` template (fill in your
|
|
35
|
-
3. Installs
|
|
32
|
+
1. Copies `scripts/`, `skill/`, `setup/`, `SKILL.md`, and `browser-agent-configs/` into `~/social-autoposter/`
|
|
33
|
+
2. Creates `config.json` from `config.example.json` and writes a blank `.env` template (fill in your S4L API key and optional `MOLTBOOK_API_KEY`)
|
|
34
|
+
3. Installs the Python helper deps via `pip3` if missing
|
|
36
35
|
4. Generates launchd plists in `~/social-autoposter/launchd/` with the user's actual `HOME` and `PATH`
|
|
37
36
|
5. Installs the Playwright MCP configs to `~/.claude/browser-agent-configs/` (twitter, reddit, linkedin) with `__HOME__` and `__NODE_BIN__` placeholders substituted. Existing files are left alone, so any window-position tweaks survive `npx social-autoposter update`.
|
|
38
37
|
6. Creates empty persistent browser profile dirs at `~/.claude/browser-profiles/{twitter,reddit,linkedin}`
|
|
@@ -48,7 +47,7 @@ npx social-autoposter update
|
|
|
48
47
|
|
|
49
48
|
Tell your Claude Code agent: **"set up social autoposter"**. The interactive wizard in `setup/SKILL.md` walks through:
|
|
50
49
|
|
|
51
|
-
1. Verifying the
|
|
50
|
+
1. Verifying the S4L API connection
|
|
52
51
|
2. Filling in `~/social-autoposter/config.json` with handles for Reddit, Twitter, LinkedIn, optional Moltbook
|
|
53
52
|
3. A 5-question interview to draft your `content_angle`
|
|
54
53
|
4. Capturing `projects` with `topics` (used by the tiered reply strategy)
|
|
@@ -65,10 +64,10 @@ launchd ──▶ skill/run-{platform}.sh ──▶ claude -p --strict-mcp-
|
|
|
65
64
|
│ │
|
|
66
65
|
│ └──▶ ~/.claude/browser-profiles/{platform}/ (persistent userDataDir)
|
|
67
66
|
│
|
|
68
|
-
├──▶ scripts/find_threads.py, top_twitter_queries.py (no browser, API
|
|
67
|
+
├──▶ scripts/find_threads.py, top_twitter_queries.py (no browser, API dedup)
|
|
69
68
|
├──▶ scripts/pick_project.py (weighted project rotation)
|
|
70
69
|
├──▶ scripts/top_performers.py (feedback report from past stats)
|
|
71
|
-
└──▶
|
|
70
|
+
└──▶ S4L HTTP API (AUTOPOSTER_API_BASE in .env)
|
|
72
71
|
```
|
|
73
72
|
|
|
74
73
|
Each `skill/run-*.sh`:
|
|
@@ -104,7 +103,7 @@ launchctl load ~/Library/LaunchAgents/com.m13v.social-twitter-cycle.plist
|
|
|
104
103
|
| `/social-autoposter engage` | Scan and reply to responses on our posts |
|
|
105
104
|
| `/social-autoposter audit` | Full browser audit of all posts |
|
|
106
105
|
|
|
107
|
-
View live stats at `https://s4l.ai/stats/<your-handle>` once posts start landing
|
|
106
|
+
View live stats at `https://s4l.ai/stats/<your-handle>` once posts start landing.
|
|
108
107
|
|
|
109
108
|
## Repo layout
|
|
110
109
|
|
|
@@ -114,7 +113,6 @@ social-autoposter/
|
|
|
114
113
|
├── bin/cli.js installer + dashboard launcher
|
|
115
114
|
├── browser-agent-configs/ Playwright MCP templates (twitter/reddit/linkedin)
|
|
116
115
|
├── config.example.json config template
|
|
117
|
-
├── schema-postgres.sql Postgres schema
|
|
118
116
|
├── setup/SKILL.md interactive setup wizard skill (locked)
|
|
119
117
|
├── scripts/ Python and JS helpers (no browser, no LLM)
|
|
120
118
|
├── skill/ shell wrappers invoked by launchd
|
package/bin/cli.js
CHANGED
|
@@ -16,7 +16,6 @@ const HOME = os.homedir();
|
|
|
16
16
|
// Files/dirs to copy from npm package to ~/social-autoposter
|
|
17
17
|
const COPY_TARGETS = [
|
|
18
18
|
'scripts',
|
|
19
|
-
'schema-postgres.sql',
|
|
20
19
|
'config.example.json',
|
|
21
20
|
'requirements.txt',
|
|
22
21
|
'SKILL.md',
|
|
@@ -39,11 +38,6 @@ MOLTBOOK_API_KEY=
|
|
|
39
38
|
# AUTOPOSTER_API_KEY only if your install uses a bearer token.
|
|
40
39
|
# AUTOPOSTER_API_BASE=https://s4l.ai
|
|
41
40
|
# AUTOPOSTER_API_KEY=
|
|
42
|
-
|
|
43
|
-
# Optional. Only the local dashboard (bin/server.js) still reads Postgres
|
|
44
|
-
# directly; the posting pipelines do not. Leave blank unless you run the
|
|
45
|
-
# dashboard. Format: postgresql://<user>:<password>@<host>/<db>?sslmode=require
|
|
46
|
-
# DATABASE_URL=
|
|
47
41
|
`;
|
|
48
42
|
|
|
49
43
|
// Never overwrite these user files during update
|
|
@@ -128,12 +122,36 @@ function pipSupportsBreakSystemPackages(pythonBin) {
|
|
|
128
122
|
return parseInt(m[1], 10) >= 23;
|
|
129
123
|
}
|
|
130
124
|
|
|
125
|
+
// True if the interpreter carries a PEP 668 EXTERNALLY-MANAGED marker in its
|
|
126
|
+
// stdlib dir (Homebrew python, Debian/Ubuntu 23+). On these, a bare
|
|
127
|
+
// `pip install` is GUARANTEED to fail with a loud "externally-managed-environment"
|
|
128
|
+
// wall of text. Detecting it up front lets pipInstall skip that doomed first
|
|
129
|
+
// attempt and go straight to --break-system-packages, so init output stays clean
|
|
130
|
+
// and doesn't falsely look like a failed dependency install when it recovers.
|
|
131
|
+
function pipIsExternallyManaged(pythonBin) {
|
|
132
|
+
const r = spawnSync(pythonBin, ['-c',
|
|
133
|
+
"import os,sys,sysconfig\n" +
|
|
134
|
+
"p=os.path.join(sysconfig.get_path('stdlib'),'EXTERNALLY-MANAGED')\n" +
|
|
135
|
+
"sys.exit(0 if os.path.exists(p) else 1)",
|
|
136
|
+
]);
|
|
137
|
+
return r.status === 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
131
140
|
// Install Python packages into a specific interpreter via `<py> -m pip install`.
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
141
|
+
// Behaviour by environment:
|
|
142
|
+
// - PEP 668 externally-managed interpreter (Homebrew python, Debian/Ubuntu 23+)
|
|
143
|
+
// with pip>=23: go STRAIGHT to --break-system-packages. The bare attempt
|
|
144
|
+
// would always fail loudly with externally-managed-environment, which made
|
|
145
|
+
// init look like "Python deps failed" even though the (silent) retry actually
|
|
146
|
+
// installed everything. No doomed first attempt, no false-alarm output.
|
|
147
|
+
// - Everything else: bare attempt, then retry with --break-system-packages only
|
|
148
|
+
// if it failed and pip supports the flag.
|
|
149
|
+
// Returns the spawnSync result of the last attempt.
|
|
135
150
|
function pipInstall(pythonBin, args) {
|
|
136
151
|
const base = ['-m', 'pip', 'install', ...args];
|
|
152
|
+
if (pipIsExternallyManaged(pythonBin) && pipSupportsBreakSystemPackages(pythonBin)) {
|
|
153
|
+
return spawnSync(pythonBin, [...base, '--break-system-packages'], { stdio: 'inherit' });
|
|
154
|
+
}
|
|
137
155
|
let r = spawnSync(pythonBin, base, { stdio: 'inherit' });
|
|
138
156
|
if (r.status !== 0 && pipSupportsBreakSystemPackages(pythonBin)) {
|
|
139
157
|
r = spawnSync(pythonBin, [...base, '--break-system-packages'], { stdio: 'inherit' });
|
|
@@ -429,7 +447,7 @@ function bootstrapVm() {
|
|
|
429
447
|
// Install Python deps from requirements.txt. installBrowserHarness only
|
|
430
448
|
// installs uv + mcp; it does NOT read requirements.txt, so without this the
|
|
431
449
|
// VM is missing websocket-client (restore_twitter_session.py aborts on
|
|
432
|
-
// import) plus
|
|
450
|
+
// import) plus playwright that the cycle scripts need.
|
|
433
451
|
installPythonDeps();
|
|
434
452
|
|
|
435
453
|
// Restore the Twitter login if we have stored cookies and the Chrome is
|
|
@@ -530,6 +548,26 @@ function installBrowserHarness() {
|
|
|
530
548
|
if (fs.existsSync(harnessBin)) {
|
|
531
549
|
spawnSync(harnessBin, ['--reload'], { stdio: 'inherit' });
|
|
532
550
|
}
|
|
551
|
+
|
|
552
|
+
// Contract check: server.py invokes `browser-harness -c <script>`. If an
|
|
553
|
+
// offline/rate-limited fetch left a stale checkout that predates the `-c`
|
|
554
|
+
// interface, the CLI still "installs" fine but every bh_run returns the
|
|
555
|
+
// usage banner and CDP looks "not connected". Verify the installed binary
|
|
556
|
+
// actually speaks `-c` (its no-arg usage string mentions it) and fail
|
|
557
|
+
// LOUDLY here instead of shipping a silently-broken twitter-harness.
|
|
558
|
+
if (fs.existsSync(harnessBin)) {
|
|
559
|
+
const probe = spawnSync(harnessBin, [], { stdio: 'pipe', encoding: 'utf8', timeout: 15000 });
|
|
560
|
+
const usage = `${probe.stdout || ''}${probe.stderr || ''}`;
|
|
561
|
+
if (!/\b-c\b/.test(usage)) {
|
|
562
|
+
console.error(' ERROR: installed browser-harness CLI does not accept `-c` (stale clone).');
|
|
563
|
+
console.error(' The twitter-harness MCP will return a usage banner / "CDP not connected".');
|
|
564
|
+
console.error(` Fix: rm -rf ${harnessDir} && re-run \`social-autoposter init\` while online,`);
|
|
565
|
+
console.error(' or manually: git clone https://github.com/browser-use/browser-harness ' + harnessDir +
|
|
566
|
+
' && ' + uvBin + ' tool install --force -e ' + harnessDir);
|
|
567
|
+
} else {
|
|
568
|
+
console.log(' browser-harness CLI verified (accepts -c).');
|
|
569
|
+
}
|
|
570
|
+
}
|
|
533
571
|
}
|
|
534
572
|
|
|
535
573
|
// Step 4: ensure mcp Python package available (server.py uses `from mcp.server.fastmcp ...`).
|
|
@@ -885,14 +923,14 @@ function installPythonDeps() {
|
|
|
885
923
|
const reqPath = path.join(PKG_ROOT, 'requirements.txt');
|
|
886
924
|
const args = fs.existsSync(reqPath)
|
|
887
925
|
? ['-r', reqPath, '-q']
|
|
888
|
-
: ['-q', '
|
|
926
|
+
: ['-q', 'playwright'];
|
|
889
927
|
// Install into the SAME interpreter the MCP server runs (SAPS_PYTHON =
|
|
890
928
|
// Homebrew python), NOT bare pip3 which on macOS targets the Xcode CLT system
|
|
891
929
|
// python — deps installed there are invisible to the scripts at runtime.
|
|
892
930
|
// pipInstall() also gates --break-system-packages on pip>=23 so it doesn't
|
|
893
931
|
// hard-fail against the ancient system pip.
|
|
894
932
|
const pythonBin = findPythonBin();
|
|
895
|
-
console.log(` installing Python deps (
|
|
933
|
+
console.log(` installing Python deps (playwright, ...) into ${pythonBin}`);
|
|
896
934
|
const r = pipInstall(pythonBin, args);
|
|
897
935
|
if (r.status !== 0) {
|
|
898
936
|
console.warn(' WARNING: pip install failed — run manually:');
|
|
@@ -995,7 +1033,17 @@ if (cmd === 'init') {
|
|
|
995
1033
|
process.argv = [process.argv[0], process.argv[1], 'import', ...process.argv.slice(3)];
|
|
996
1034
|
require('./cookie-helper.js');
|
|
997
1035
|
} else if (!cmd) {
|
|
998
|
-
|
|
1036
|
+
// The dashboard server (bin/server.js) is a local-only operator tool and is
|
|
1037
|
+
// NOT shipped in the published package (it talks directly to Postgres). When
|
|
1038
|
+
// it's absent, fall back to usage help instead of crashing on a missing require.
|
|
1039
|
+
if (fs.existsSync(path.join(__dirname, 'server.js'))) {
|
|
1040
|
+
require('./server.js');
|
|
1041
|
+
} else {
|
|
1042
|
+
console.log('social-autoposter — automated social posting for Claude agents');
|
|
1043
|
+
console.log('');
|
|
1044
|
+
console.log('The local dashboard is not part of the published package.');
|
|
1045
|
+
console.log('Run `npx social-autoposter init` to set up, then drive it from your Claude agent.');
|
|
1046
|
+
}
|
|
999
1047
|
} else {
|
|
1000
1048
|
console.log('social-autoposter — automated social posting for Claude agents');
|
|
1001
1049
|
console.log('');
|
package/mcp/dist/repo.js
CHANGED
|
@@ -10,7 +10,7 @@ const __dirname = path.dirname(__filename);
|
|
|
10
10
|
// dist/repo.js -> repo root is two levels up (mcp/dist -> mcp -> repo root).
|
|
11
11
|
// Override with SAPS_REPO_DIR for non-standard installs.
|
|
12
12
|
export const REPO_DIR = process.env.SAPS_REPO_DIR || path.resolve(__dirname, "..", "..");
|
|
13
|
-
// Python used by the pipeline (
|
|
13
|
+
// Python used by the pipeline (deps from requirements.txt). Override per-install.
|
|
14
14
|
export const PYTHON = process.env.SAPS_PYTHON || "python3";
|
|
15
15
|
// The locked pipeline script (run-twitter-cycle.sh) writes the draft plan to a
|
|
16
16
|
// HARDCODED /tmp path (`PLAN_FILE="/tmp/twitter_cycle_plan_<batch>.json"`), and the
|
|
@@ -21,7 +21,7 @@ export const PYTHON = process.env.SAPS_PYTHON || "python3";
|
|
|
21
21
|
// "No drafts in batch ...". Default to /tmp to match the script; allow an explicit
|
|
22
22
|
// override for non-standard installs.
|
|
23
23
|
export const TMP_DIR = process.env.SAPS_TMP_DIR || "/tmp";
|
|
24
|
-
// Spawn a process inside the repo, inheriting the repo env (
|
|
24
|
+
// Spawn a process inside the repo, inheriting the repo env (API base + keys
|
|
25
25
|
// come from the install's environment / .env loaded by the scripts themselves).
|
|
26
26
|
export function run(cmd, args, opts = {}) {
|
|
27
27
|
return new Promise((resolve) => {
|
package/mcp/install.mjs
CHANGED
|
@@ -34,7 +34,7 @@ const nodeBin = whichNode();
|
|
|
34
34
|
function whichPython() {
|
|
35
35
|
// Prefer a real Homebrew python (has the pipeline's deps) over the macOS
|
|
36
36
|
// /usr/bin/python3 stub, which can trigger an Xcode CLT install prompt and
|
|
37
|
-
// often lacks
|
|
37
|
+
// often lacks the pipeline deps.
|
|
38
38
|
const candidates = ["/opt/homebrew/bin/python3", "/usr/local/bin/python3"];
|
|
39
39
|
try {
|
|
40
40
|
const found = execSync("command -v python3 2>/dev/null", { shell: "/bin/bash" })
|
package/mcp/manifest.json
CHANGED
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"saps_python": {
|
|
52
52
|
"type": "string",
|
|
53
53
|
"title": "Python interpreter",
|
|
54
|
-
"description": "Python used by the pipeline (
|
|
54
|
+
"description": "Python used by the pipeline (needs the deps in requirements.txt). Leave as default unless you know you need a different one.",
|
|
55
55
|
"required": false,
|
|
56
56
|
"default": "/opt/homebrew/bin/python3"
|
|
57
57
|
}
|
|
Binary file
|
|
@@ -246,8 +246,13 @@ def ensure_chrome() -> dict:
|
|
|
246
246
|
PID_FILE.write_text(str(proc.pid))
|
|
247
247
|
_log(f"launched Chrome pid={proc.pid} port={PORT} profile={PROFILE_DIR} headless={HEADLESS}")
|
|
248
248
|
|
|
249
|
-
# Wait for CDP to be ready.
|
|
250
|
-
|
|
249
|
+
# Wait for CDP to be ready. First launch on a cold/fresh machine has to
|
|
250
|
+
# create the profile and run Chrome's first-run setup, which routinely
|
|
251
|
+
# exceeds 15s on a slow VM; an over-tight deadline returns launch_timeout
|
|
252
|
+
# and the caller runs against a port that was about to come up. 30s is the
|
|
253
|
+
# safe floor (override with BH_LAUNCH_TIMEOUT_SEC).
|
|
254
|
+
launch_timeout = int(os.environ.get("BH_LAUNCH_TIMEOUT_SEC", "30"))
|
|
255
|
+
deadline = time.time() + launch_timeout
|
|
251
256
|
while time.time() < deadline:
|
|
252
257
|
if _cdp_alive():
|
|
253
258
|
return {"status": "started", "pid": proc.pid, "cdp": CDP_URL}
|
|
@@ -258,6 +263,8 @@ def ensure_chrome() -> dict:
|
|
|
258
263
|
"pid": proc.pid,
|
|
259
264
|
"cdp": CDP_URL,
|
|
260
265
|
"log": str(LOG_FILE),
|
|
266
|
+
"waited_sec": launch_timeout,
|
|
267
|
+
"log_tail": _chrome_log_tail(),
|
|
261
268
|
}
|
|
262
269
|
|
|
263
270
|
|
|
@@ -331,6 +338,56 @@ def stop_chrome() -> dict:
|
|
|
331
338
|
|
|
332
339
|
# --- browser-harness exec wrapper ---
|
|
333
340
|
|
|
341
|
+
def _chrome_log_tail(lines: int = 25) -> str:
|
|
342
|
+
"""Last `lines` of the managed-Chrome log, for surfacing in CDP errors."""
|
|
343
|
+
try:
|
|
344
|
+
text = LOG_FILE.read_text(errors="replace")
|
|
345
|
+
except (FileNotFoundError, OSError):
|
|
346
|
+
return ""
|
|
347
|
+
return "\n".join(text.splitlines()[-lines:])
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _ensure_cdp_ready() -> dict | None:
|
|
351
|
+
"""Guarantee CDP is actually answering on PORT before we shell out to the
|
|
352
|
+
harness CLI. Returns None when CDP is live; otherwise returns a structured,
|
|
353
|
+
actionable error dict (and leaves the chrome log tail attached).
|
|
354
|
+
|
|
355
|
+
Without this gate, ensure_chrome() failures (no_chrome_binary,
|
|
356
|
+
launch_timeout) were swallowed and the CLI ran against a dead port, so the
|
|
357
|
+
agent saw a cryptic usage banner / connection error instead of the real
|
|
358
|
+
cause. This is the #1 fresh-install failure mode."""
|
|
359
|
+
res = ensure_chrome()
|
|
360
|
+
if _cdp_alive():
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
# One self-heal attempt: a stale Chrome bound to the port but not speaking
|
|
364
|
+
# CDP (crashed renderer, half-dead profile) won't recover on its own.
|
|
365
|
+
_log(f"CDP not alive after ensure_chrome (status={res.get('status')}); attempting stop+relaunch")
|
|
366
|
+
stop_chrome()
|
|
367
|
+
time.sleep(1.0)
|
|
368
|
+
res = ensure_chrome()
|
|
369
|
+
if _cdp_alive():
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
status = res.get("status", "unknown")
|
|
373
|
+
if status == "no_chrome_binary":
|
|
374
|
+
hint = res.get("hint", "Install Chrome/Chromium or set BH_CHROME_BIN.")
|
|
375
|
+
else:
|
|
376
|
+
hint = (
|
|
377
|
+
f"Chrome did not expose CDP on {CDP_URL} (status={status}). "
|
|
378
|
+
"On a headless Linux box ensure BH_HEADLESS=1 and a Chrome binary "
|
|
379
|
+
"are present; on macOS make sure no other Chrome owns the profile. "
|
|
380
|
+
f"See {LOG_FILE}."
|
|
381
|
+
)
|
|
382
|
+
return {
|
|
383
|
+
"ok": False,
|
|
384
|
+
"error": f"browser-harness CDP not connected: {hint}",
|
|
385
|
+
"cdp": CDP_URL,
|
|
386
|
+
"ensure_chrome": res,
|
|
387
|
+
"chrome_log_tail": _chrome_log_tail(),
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
|
|
334
391
|
def _run_harness(script: str, timeout: int = EXEC_TIMEOUT_SEC) -> dict:
|
|
335
392
|
if not shutil.which(BROWSER_HARNESS_BIN) and not Path(BROWSER_HARNESS_BIN).exists():
|
|
336
393
|
return {
|
|
@@ -341,7 +398,9 @@ def _run_harness(script: str, timeout: int = EXEC_TIMEOUT_SEC) -> dict:
|
|
|
341
398
|
),
|
|
342
399
|
}
|
|
343
400
|
|
|
344
|
-
|
|
401
|
+
cdp_err = _ensure_cdp_ready()
|
|
402
|
+
if cdp_err is not None:
|
|
403
|
+
return cdp_err
|
|
345
404
|
|
|
346
405
|
env = os.environ.copy()
|
|
347
406
|
env["BU_CDP_URL"] = CDP_URL
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "social-autoposter",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.28",
|
|
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"
|
|
@@ -12,7 +12,13 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"bin/",
|
|
15
|
+
"!bin/server.js",
|
|
16
|
+
"!bin/auth.js",
|
|
15
17
|
"scripts/*.py",
|
|
18
|
+
"!scripts/db_direct.py",
|
|
19
|
+
"!scripts/_dm_record_sent.sh",
|
|
20
|
+
"!scripts/send_batch_dms.sh",
|
|
21
|
+
"!scripts/mint_podlog_subpage_*.py",
|
|
16
22
|
"!scripts/tmp_*.py",
|
|
17
23
|
"!scripts/insert_post*.py",
|
|
18
24
|
"!scripts/_insert_post_*.py",
|
|
@@ -34,7 +40,6 @@
|
|
|
34
40
|
"!scripts/seed_dashboard_users.py",
|
|
35
41
|
"!scripts/send_dashboard_invite.py",
|
|
36
42
|
"scripts/*.sh",
|
|
37
|
-
"schema-postgres.sql",
|
|
38
43
|
"config.example.json",
|
|
39
44
|
"requirements.txt",
|
|
40
45
|
"SKILL.md",
|
|
@@ -69,8 +74,10 @@
|
|
|
69
74
|
"node": ">=16"
|
|
70
75
|
},
|
|
71
76
|
"dependencies": {
|
|
72
|
-
"firebase-admin": "^13.8.0",
|
|
73
|
-
"pg": "^8.20.0",
|
|
74
77
|
"ws": "^8.0.0"
|
|
78
|
+
},
|
|
79
|
+
"devDependencies": {
|
|
80
|
+
"firebase-admin": "^13.8.0",
|
|
81
|
+
"pg": "^8.20.0"
|
|
75
82
|
}
|
|
76
83
|
}
|
package/requirements.txt
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
# Playwright browser binaries are downloaded separately (same interpreter:
|
|
5
5
|
# `<saps_python> -m playwright install chromium`).
|
|
6
6
|
|
|
7
|
-
psycopg2-binary
|
|
8
7
|
playwright
|
|
9
8
|
# CDP client used by scripts/restore_twitter_session.py and the cookie-grab/
|
|
10
9
|
# CDP-driven helpers. Required on AppMaker VMs for the post-substitution
|
package/scripts/db.py
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Shared
|
|
2
|
+
"""Shared env loader for social-autoposter (clean, shipped).
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
The published package talks to the central store exclusively through the S4L
|
|
5
|
+
HTTP API (scripts/http_api.py). It ships NO direct database dependency: no
|
|
6
|
+
psycopg2, no DATABASE_URL requirement.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
This module provides `load_env()` (the only DB-agnostic helper every pipeline
|
|
9
|
+
needs) and, for LOCAL operator installs only, re-exports the direct-Postgres
|
|
10
|
+
connection layer from `db_direct.py` when that file is present. `db_direct.py`
|
|
11
|
+
is excluded from the npm tarball, so on a clean install the direct-DB symbols
|
|
12
|
+
resolve to a hard-error stub instead of importing psycopg2.
|
|
13
|
+
|
|
14
|
+
.env is read from ~/social-autoposter/.env (pre-filled on install).
|
|
8
15
|
"""
|
|
9
16
|
|
|
10
17
|
import os
|
|
11
|
-
import re
|
|
12
|
-
import sys
|
|
13
18
|
|
|
14
19
|
ENV_PATH = os.path.expanduser("~/social-autoposter/.env")
|
|
15
20
|
|
|
@@ -24,181 +29,29 @@ def load_env():
|
|
|
24
29
|
os.environ.setdefault(k.strip(), v.strip())
|
|
25
30
|
|
|
26
31
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"Temporary failure in name resolution",
|
|
45
|
-
"Name or service not known",
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def _connect_with_retry(url):
|
|
50
|
-
"""psycopg2.connect with 3-try backoff on DNS resolution failures.
|
|
51
|
-
|
|
52
|
-
Transient DNS blips from the local resolver (observed ~40 min on
|
|
53
|
-
2026-04-23 PDT) crashed every tick that happened to run during the
|
|
54
|
-
window. Retry only on name-resolution OperationalError messages so
|
|
55
|
-
auth or hard-down errors fail fast.
|
|
56
|
-
"""
|
|
57
|
-
import time
|
|
58
|
-
import psycopg2
|
|
59
|
-
backoffs = (1.0, 2.0)
|
|
60
|
-
last_exc = None
|
|
61
|
-
for attempt in range(3):
|
|
62
|
-
try:
|
|
63
|
-
return psycopg2.connect(url, keepalives=1,
|
|
64
|
-
keepalives_idle=30,
|
|
65
|
-
keepalives_interval=10,
|
|
66
|
-
keepalives_count=5)
|
|
67
|
-
except psycopg2.OperationalError as exc:
|
|
68
|
-
msg = str(exc)
|
|
69
|
-
if not any(m in msg for m in _DNS_TRANSIENT_MARKERS):
|
|
70
|
-
raise
|
|
71
|
-
last_exc = exc
|
|
72
|
-
if attempt < len(backoffs):
|
|
73
|
-
print(f"[db] DNS transient, retrying in {backoffs[attempt]}s: {msg.strip()}",
|
|
74
|
-
file=sys.stderr)
|
|
75
|
-
time.sleep(backoffs[attempt])
|
|
76
|
-
raise last_exc
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
class PGConn:
|
|
80
|
-
"""Thin psycopg2 wrapper with a sqlite3-compatible execute/commit/close API."""
|
|
81
|
-
|
|
82
|
-
def __init__(self, conn, url=None):
|
|
83
|
-
import psycopg2.extras
|
|
84
|
-
self._conn = conn
|
|
85
|
-
self._url = url
|
|
86
|
-
self._cursor_factory = psycopg2.extras.DictCursor
|
|
87
|
-
self._apply_session_defaults()
|
|
88
|
-
|
|
89
|
-
def _apply_session_defaults(self):
|
|
90
|
-
# 5 min per-statement ceiling. Prevents silent hangs when the
|
|
91
|
-
# pooler drops an idle socket and psycopg2's recv stalls forever
|
|
92
|
-
# (originally observed 2026-04-23/24 on Neon under stats.py
|
|
93
|
-
# --github-only; kept after Cloud SQL migration as defensive). The
|
|
94
|
-
# pooler rejects libpq `options=` at connect time, so we set this via
|
|
95
|
-
# SQL after connect. Reapplied after commit() because transaction-mode
|
|
96
|
-
# pooling resets session state on COMMIT.
|
|
97
|
-
try:
|
|
98
|
-
cur = self._conn.cursor()
|
|
99
|
-
cur.execute("SET statement_timeout = 300000")
|
|
100
|
-
cur.close()
|
|
101
|
-
except Exception:
|
|
102
|
-
pass
|
|
103
|
-
|
|
104
|
-
def _reconnect(self):
|
|
105
|
-
try:
|
|
106
|
-
self._conn.close()
|
|
107
|
-
except Exception:
|
|
108
|
-
pass
|
|
109
|
-
self._conn = _connect_with_retry(self._url)
|
|
110
|
-
self._apply_session_defaults()
|
|
111
|
-
|
|
112
|
-
def execute(self, sql, params=None):
|
|
113
|
-
import psycopg2
|
|
114
|
-
sql = _translate_sql(sql)
|
|
115
|
-
try:
|
|
116
|
-
cur = self._conn.cursor(cursor_factory=self._cursor_factory)
|
|
117
|
-
if params is not None:
|
|
118
|
-
cur.execute(sql, list(params))
|
|
119
|
-
else:
|
|
120
|
-
cur.execute(sql)
|
|
121
|
-
return cur
|
|
122
|
-
except psycopg2.OperationalError:
|
|
123
|
-
self._reconnect()
|
|
124
|
-
cur = self._conn.cursor(cursor_factory=self._cursor_factory)
|
|
125
|
-
if params is not None:
|
|
126
|
-
cur.execute(sql, list(params))
|
|
127
|
-
else:
|
|
128
|
-
cur.execute(sql)
|
|
129
|
-
return cur
|
|
130
|
-
|
|
131
|
-
def commit(self):
|
|
132
|
-
import psycopg2
|
|
133
|
-
try:
|
|
134
|
-
self._conn.commit()
|
|
135
|
-
except psycopg2.OperationalError:
|
|
136
|
-
self._reconnect()
|
|
137
|
-
self._conn.commit()
|
|
138
|
-
# The transaction-mode pooler resets session state on COMMIT, so
|
|
139
|
-
# re-apply statement_timeout for the next transaction.
|
|
140
|
-
self._apply_session_defaults()
|
|
32
|
+
# Re-export the direct-Postgres layer when running on a local operator install
|
|
33
|
+
# (db_direct.py present). In the published package db_direct.py is absent, so
|
|
34
|
+
# these names resolve to a stub that fails loudly if anything tries to open a
|
|
35
|
+
# direct DB connection — by design, the shipped pipelines use the HTTP API.
|
|
36
|
+
try:
|
|
37
|
+
from db_direct import ( # noqa: F401
|
|
38
|
+
get_conn,
|
|
39
|
+
PGConn,
|
|
40
|
+
snapshot_post_views,
|
|
41
|
+
)
|
|
42
|
+
except ImportError:
|
|
43
|
+
def _no_direct_db(*_args, **_kwargs):
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
"Direct database access is not available in this build. "
|
|
46
|
+
"The published social-autoposter package uses the S4L HTTP API "
|
|
47
|
+
"(scripts/http_api.py); set AUTOPOSTER_API_BASE in ~/social-autoposter/.env."
|
|
48
|
+
)
|
|
141
49
|
|
|
142
|
-
def
|
|
143
|
-
|
|
50
|
+
def get_conn(*_args, **_kwargs): # noqa: F811
|
|
51
|
+
return _no_direct_db()
|
|
144
52
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
def row_factory(self):
|
|
53
|
+
def snapshot_post_views(*_args, **_kwargs): # noqa: F811
|
|
54
|
+
# No-op stub: stats snapshots are an operator-local concern.
|
|
148
55
|
return None
|
|
149
56
|
|
|
150
|
-
|
|
151
|
-
def row_factory(self, val):
|
|
152
|
-
pass
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def snapshot_post_views(db, post_id, views):
|
|
156
|
-
"""UPSERT one row of post_views_daily with today's view/upvote/comment count.
|
|
157
|
-
|
|
158
|
-
Called from the Reddit + Twitter refresh jobs whenever a fresh view count
|
|
159
|
-
is observed for a post. Later observations on the same day overwrite the
|
|
160
|
-
earlier ones so end-of-day has the final number. The dashboard computes
|
|
161
|
-
daily deltas with LAG() over (post_id ORDER BY day) to render gains
|
|
162
|
-
earned on day D across all posts.
|
|
163
|
-
|
|
164
|
-
Upvotes and comments are read from posts.upvotes / posts.comments_count at
|
|
165
|
-
write time. Callers UPDATE those columns on the post row immediately
|
|
166
|
-
before calling this function, so the subselects pick up the fresh values
|
|
167
|
-
without requiring each caller to pass them explicitly.
|
|
168
|
-
"""
|
|
169
|
-
if post_id is None or views is None:
|
|
170
|
-
return
|
|
171
|
-
try:
|
|
172
|
-
db.execute(
|
|
173
|
-
"INSERT INTO post_views_daily (post_id, day, views, upvotes, comments, captured_at) "
|
|
174
|
-
"VALUES (%s, CURRENT_DATE, %s, "
|
|
175
|
-
" (SELECT upvotes FROM posts WHERE id = %s), "
|
|
176
|
-
" (SELECT comments_count FROM posts WHERE id = %s), "
|
|
177
|
-
" NOW()) "
|
|
178
|
-
"ON CONFLICT (post_id, day) DO UPDATE SET "
|
|
179
|
-
" views = EXCLUDED.views, "
|
|
180
|
-
" upvotes = EXCLUDED.upvotes, "
|
|
181
|
-
" comments = EXCLUDED.comments, "
|
|
182
|
-
" captured_at = EXCLUDED.captured_at",
|
|
183
|
-
[post_id, int(views), post_id, post_id],
|
|
184
|
-
)
|
|
185
|
-
except Exception:
|
|
186
|
-
pass
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def get_conn():
|
|
190
|
-
"""Return a PGConn connected to the central Postgres database."""
|
|
191
|
-
load_env()
|
|
192
|
-
url = os.environ.get('DATABASE_URL')
|
|
193
|
-
if not url:
|
|
194
|
-
print("ERROR: DATABASE_URL not set in ~/social-autoposter/.env", file=sys.stderr)
|
|
195
|
-
print(" Re-run: npx social-autoposter init", file=sys.stderr)
|
|
196
|
-
sys.exit(1)
|
|
197
|
-
try:
|
|
198
|
-
import psycopg2 # noqa: F401 — verifies availability before retry helper imports it
|
|
199
|
-
except ImportError:
|
|
200
|
-
print("ERROR: psycopg2-binary not installed.", file=sys.stderr)
|
|
201
|
-
print(" Run: pip3 install psycopg2-binary", file=sys.stderr)
|
|
202
|
-
sys.exit(1)
|
|
203
|
-
conn = _connect_with_retry(url)
|
|
204
|
-
return PGConn(conn, url=url)
|
|
57
|
+
PGConn = None
|
|
@@ -37,10 +37,11 @@ acquire_lock "dm-outreach-twitter" 2700
|
|
|
37
37
|
REPO_DIR="$HOME/social-autoposter"
|
|
38
38
|
SKILL_FILE="$REPO_DIR/SKILL.md"
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
# 2026-06-02: removed the vestigial DATABASE_URL gate. This rail talks to the
|
|
41
|
+
# central store exclusively through the S4L HTTP API (scan_dm_candidates.py,
|
|
42
|
+
# dm_outreach_twitter_helper.py, log_run.py all use scripts/http_api.py). No
|
|
43
|
+
# direct Postgres connection is opened here, matching dm-outreach-reddit.sh and
|
|
44
|
+
# dm-outreach-linkedin.sh (migrated 2026-05-12).
|
|
44
45
|
# (LOG_DIR/LOG_FILE bootstrapped at top of script.)
|
|
45
46
|
|
|
46
47
|
log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
|
|
@@ -1260,6 +1260,13 @@ if [ "$EXTRACT_EXIT" -ne 0 ] || [ ! -f "$RAW_FILE" ]; then
|
|
|
1260
1260
|
# context_overflow) or just "model found nothing relevant". Classify
|
|
1261
1261
|
# the failure for the post-loop log_run summary; the loop control below
|
|
1262
1262
|
# decides whether to retry or give up.
|
|
1263
|
+
# SCAN_OUTPUT was a stale leftover from the pre-lean design (when the scan's
|
|
1264
|
+
# stdout was captured into a shell var); the lean Phase 1 loop now tees its
|
|
1265
|
+
# output to $LOG_FILE instead, so an empty-scan attempt hit `set -u` and
|
|
1266
|
+
# aborted the whole cycle here. Feed the classifier the recent log tail (the
|
|
1267
|
+
# actual scan output, where harness/Anthropic error signatures land) so we
|
|
1268
|
+
# still distinguish a real error from "found nothing relevant".
|
|
1269
|
+
SCAN_OUTPUT=$(tail -n 80 "$LOG_FILE" 2>/dev/null || true)
|
|
1263
1270
|
PHASE1_REASON_LATEST=$(echo "$SCAN_OUTPUT" | python3 "$REPO_DIR/scripts/classify_run_error.py" 2>/dev/null)
|
|
1264
1271
|
[ -z "$PHASE1_REASON_LATEST" ] && PHASE1_REASON_LATEST="phase1_no_tweets"
|
|
1265
1272
|
LAST_PHASE1_REASON="$PHASE1_REASON_LATEST"
|