social-autoposter 1.6.27 → 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 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
- Posts are written to a Postgres database via `DATABASE_URL` in `~/social-autoposter/.env`. Bring your own Postgres DB and apply `schema-postgres.sql` once. Each platform drives its own persistent Playwright MCP browser profile, so logins survive across runs.
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; `psycopg2-binary` is auto-installed by the installer)
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`, `schema-postgres.sql`, and `browser-agent-configs/` into `~/social-autoposter/`
34
- 2. Creates `config.json` from `config.example.json` and writes a blank `.env` template (fill in your own `DATABASE_URL` and optional `MOLTBOOK_API_KEY`)
35
- 3. Installs `psycopg2-binary` via `pip3` if missing
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 Postgres connection
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 + DB dedup)
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
- └──▶ Postgres (DATABASE_URL in .env)
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 in Postgres.
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
- // Retries with --break-system-packages only when the resolved pip supports it
133
- // (PEP 668 environments: Homebrew python, Debian/Ubuntu 23+). Returns the
134
- // spawnSync result of the last attempt.
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 psycopg2-binary/playwright that the cycle scripts need.
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', 'psycopg2-binary', 'playwright'];
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 (psycopg2-binary, playwright, ...) into ${pythonBin}`);
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
- require('./server.js');
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 (psycopg2 etc). Override per-install.
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 (DATABASE_URL etc
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 psycopg2 etc.
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 (must have psycopg2 etc). Leave as default unless you know you need a different one.",
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
  }
@@ -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
- deadline = time.time() + 15
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
- ensure_chrome()
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.27",
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 Postgres connection for social-autoposter.
2
+ """Shared env loader for social-autoposter (clean, shipped).
3
3
 
4
- Provides a thin psycopg2 wrapper with a sqlite3-compatible API so all
5
- scripts can use the same SQL without changes to query logic.
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
- DATABASE_URL is read from ~/social-autoposter/.env (pre-filled on install).
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
- def _translate_sql(sql):
28
- """Translate SQLite-specific SQL syntax to PostgreSQL."""
29
- # ? placeholders -> %s
30
- sql = sql.replace('?', '%s')
31
- # datetime('now', '-N hours') -> NOW() - INTERVAL 'N hours'
32
- sql = re.sub(r"datetime\('now',\s*'-(\d+) hours'\)", r"NOW() - INTERVAL '\1 hours'", sql)
33
- # datetime('now', '-N days') -> NOW() - INTERVAL 'N days'
34
- sql = re.sub(r"datetime\('now',\s*'-(\d+) days'\)", r"NOW() - INTERVAL '\1 days'", sql)
35
- # datetime('now') -> NOW()
36
- sql = re.sub(r"datetime\('now'\)", 'NOW()', sql)
37
- # status_checked_at=datetime('now') already handled above
38
- return sql
39
-
40
-
41
- _DNS_TRANSIENT_MARKERS = (
42
- "could not translate host name",
43
- "nodename nor servname provided",
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 close(self):
143
- self._conn.close()
50
+ def get_conn(*_args, **_kwargs): # noqa: F811
51
+ return _no_direct_db()
144
52
 
145
- # No-op to absorb sqlite3.Row assignments
146
- @property
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
- @row_factory.setter
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
- if [ -z "${DATABASE_URL:-}" ]; then
41
- echo "ERROR: DATABASE_URL not set in ~/social-autoposter/.env"
42
- exit 1
43
- fi
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"; }