social-autoposter 1.6.22 → 1.6.24
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 +60 -20
- package/mcp/dist/index.js +6 -1
- package/mcp/install.mjs +5 -24
- package/package.json +1 -1
- package/requirements.txt +4 -3
- package/scripts/ingest_human_seo_replies.py +24 -33
- package/scripts/twitter_browser.py +8 -1
package/bin/cli.js
CHANGED
|
@@ -96,6 +96,45 @@ function findUvBin() {
|
|
|
96
96
|
return found && fs.existsSync(found) ? found : '';
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
// Locate the Python the MCP server is actually configured to run (SAPS_PYTHON).
|
|
100
|
+
// mcp/install.mjs picks /opt/homebrew/bin/python3 (or /usr/local/bin/python3)
|
|
101
|
+
// and stamps it into the MCP config, so Python deps MUST be installed into that
|
|
102
|
+
// SAME interpreter. Bare `pip3`/`python3` on macOS usually resolves to the
|
|
103
|
+
// Xcode CLT system python (3.9.x with pip 21.x), which is both the wrong target
|
|
104
|
+
// and too old to understand --break-system-packages. Falls back to `python3`.
|
|
105
|
+
function findPythonBin() {
|
|
106
|
+
const candidates = ['/opt/homebrew/bin/python3', '/usr/local/bin/python3'];
|
|
107
|
+
for (const c of candidates) {
|
|
108
|
+
if (fs.existsSync(c)) return c;
|
|
109
|
+
}
|
|
110
|
+
const which = spawnSync('command', ['-v', 'python3'], { shell: true, encoding: 'utf8' });
|
|
111
|
+
const found = (which.stdout || '').trim().split('\n')[0];
|
|
112
|
+
return found && fs.existsSync(found) ? found : 'python3';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// True if `<pythonBin> -m pip` is new enough (pip >= 23.0) to accept
|
|
116
|
+
// --break-system-packages. Older pips treat the flag as an unknown option and
|
|
117
|
+
// hard-fail, so we must not pass it blindly on the retry.
|
|
118
|
+
function pipSupportsBreakSystemPackages(pythonBin) {
|
|
119
|
+
const v = spawnSync(pythonBin, ['-m', 'pip', '--version'], { encoding: 'utf8' });
|
|
120
|
+
const m = (v.stdout || '').match(/pip\s+(\d+)\.(\d+)/);
|
|
121
|
+
if (!m) return false;
|
|
122
|
+
return parseInt(m[1], 10) >= 23;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Install Python packages into a specific interpreter via `<py> -m pip install`.
|
|
126
|
+
// Retries with --break-system-packages only when the resolved pip supports it
|
|
127
|
+
// (PEP 668 environments: Homebrew python, Debian/Ubuntu 23+). Returns the
|
|
128
|
+
// spawnSync result of the last attempt.
|
|
129
|
+
function pipInstall(pythonBin, args) {
|
|
130
|
+
const base = ['-m', 'pip', 'install', ...args];
|
|
131
|
+
let r = spawnSync(pythonBin, base, { stdio: 'inherit' });
|
|
132
|
+
if (r.status !== 0 && pipSupportsBreakSystemPackages(pythonBin)) {
|
|
133
|
+
r = spawnSync(pythonBin, [...base, '--break-system-packages'], { stdio: 'inherit' });
|
|
134
|
+
}
|
|
135
|
+
return r;
|
|
136
|
+
}
|
|
137
|
+
|
|
99
138
|
function installBrowserAgentConfigs() {
|
|
100
139
|
const nodeBin = path.dirname(process.execPath);
|
|
101
140
|
const uvBin = findUvBin() || path.join(HOME, '.local', 'bin', 'uv');
|
|
@@ -489,11 +528,13 @@ function installBrowserHarness() {
|
|
|
489
528
|
|
|
490
529
|
// Step 4: ensure mcp Python package available (server.py uses `from mcp.server.fastmcp ...`).
|
|
491
530
|
// server.py is shebanged through `uv run --with mcp ...` so this is belt-and-suspenders;
|
|
492
|
-
// we install it into the
|
|
493
|
-
|
|
494
|
-
|
|
531
|
+
// we install it into the SAPS_PYTHON interpreter (the same Homebrew python the MCP
|
|
532
|
+
// server is configured to use), NOT bare pip3 which targets the Xcode CLT system python.
|
|
533
|
+
const harnessPython = findPythonBin();
|
|
534
|
+
console.log(` ensuring mcp>=1.0.0 Python package is importable (${harnessPython})...`);
|
|
535
|
+
const pip = pipInstall(harnessPython, ['-q', 'mcp>=1.0.0']);
|
|
495
536
|
if (pip.status !== 0) {
|
|
496
|
-
|
|
537
|
+
console.warn(' WARNING: could not install mcp Python package; server.py still runs via `uv run --with mcp`.');
|
|
497
538
|
}
|
|
498
539
|
|
|
499
540
|
// Step 5: copy our shipped server.py into the canonical install location.
|
|
@@ -836,30 +877,29 @@ function update() {
|
|
|
836
877
|
// browser binary; we run `playwright install chromium` after the pip install.
|
|
837
878
|
function installPythonDeps() {
|
|
838
879
|
const reqPath = path.join(PKG_ROOT, 'requirements.txt');
|
|
839
|
-
const
|
|
840
|
-
? ['
|
|
841
|
-
: ['
|
|
842
|
-
|
|
843
|
-
//
|
|
844
|
-
//
|
|
845
|
-
// (
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
}
|
|
880
|
+
const args = fs.existsSync(reqPath)
|
|
881
|
+
? ['-r', reqPath, '-q']
|
|
882
|
+
: ['-q', 'psycopg2-binary', 'playwright'];
|
|
883
|
+
// Install into the SAME interpreter the MCP server runs (SAPS_PYTHON =
|
|
884
|
+
// Homebrew python), NOT bare pip3 which on macOS targets the Xcode CLT system
|
|
885
|
+
// python — deps installed there are invisible to the scripts at runtime.
|
|
886
|
+
// pipInstall() also gates --break-system-packages on pip>=23 so it doesn't
|
|
887
|
+
// hard-fail against the ancient system pip.
|
|
888
|
+
const pythonBin = findPythonBin();
|
|
889
|
+
console.log(` installing Python deps (psycopg2-binary, playwright, ...) into ${pythonBin}`);
|
|
890
|
+
const r = pipInstall(pythonBin, args);
|
|
851
891
|
if (r.status !== 0) {
|
|
852
|
-
console.warn(' WARNING:
|
|
853
|
-
console.warn(`
|
|
892
|
+
console.warn(' WARNING: pip install failed — run manually:');
|
|
893
|
+
console.warn(` ${pythonBin} -m pip install ${args.join(' ')} --break-system-packages`);
|
|
854
894
|
return;
|
|
855
895
|
}
|
|
856
896
|
// Playwright needs its browser binary downloaded separately. Chromium
|
|
857
897
|
// is the only engine the repo uses today; skip Firefox/WebKit.
|
|
858
898
|
console.log(' installing Playwright Chromium binary (one-time, ~150MB)...');
|
|
859
|
-
const pw = spawnSync(
|
|
899
|
+
const pw = spawnSync(pythonBin, ['-m', 'playwright', 'install', 'chromium'], { stdio: 'inherit' });
|
|
860
900
|
if (pw.status !== 0) {
|
|
861
901
|
console.warn(' WARNING: playwright install chromium failed — run manually:');
|
|
862
|
-
console.warn(
|
|
902
|
+
console.warn(` ${pythonBin} -m playwright install chromium`);
|
|
863
903
|
}
|
|
864
904
|
}
|
|
865
905
|
|
package/mcp/dist/index.js
CHANGED
|
@@ -242,7 +242,12 @@ async function postApproved(batchId, plan) {
|
|
|
242
242
|
return { attempted: 0, exit_code: 0, summary: "nothing approved" };
|
|
243
243
|
const approvedBatch = `${batchId}_approved`;
|
|
244
244
|
writePlan(approvedBatch, { ...plan, candidates: approved });
|
|
245
|
-
|
|
245
|
+
// SAPS_SKIP_CAMPAIGN_SUFFIX=1: manual/reviewed posts from this MCP draft_cycle
|
|
246
|
+
// never get the active-campaign suffix (e.g. " written with ai") appended.
|
|
247
|
+
// twitter_browser.py's reply handler reads this env (inherited through
|
|
248
|
+
// twitter_post_plan.py's subprocess). The cron pipeline doesn't set it, so the
|
|
249
|
+
// A/B disclosure experiment keeps running on autopilot/cron and on Reddit.
|
|
250
|
+
const res = await runPython("scripts/twitter_post_plan.py", ["--plan", planPath(approvedBatch)], { timeoutMs: 900_000, env: { SAPS_SKIP_CAMPAIGN_SUFFIX: "1" } });
|
|
246
251
|
let summary = res.stdout.trim();
|
|
247
252
|
try {
|
|
248
253
|
const lines = res.stdout.trim().split("\n");
|
package/mcp/install.mjs
CHANGED
|
@@ -153,32 +153,13 @@ if (ok === targets.length) {
|
|
|
153
153
|
console.log("server: " + distEntry);
|
|
154
154
|
console.log("");
|
|
155
155
|
console.log(
|
|
156
|
-
"
|
|
157
|
-
);
|
|
158
|
-
console.log(
|
|
159
|
-
"session yet (MCP servers load at launch, not per-tab). End your reply to the",
|
|
160
|
-
);
|
|
161
|
-
console.log(
|
|
162
|
-
"user with EXACTLY the following three steps as the FINAL lines of your response,",
|
|
163
|
-
);
|
|
164
|
-
console.log(
|
|
165
|
-
"each on its own line, in bold, numbered, with nothing after them:",
|
|
156
|
+
"The MCP server is registered but not yet loaded (MCP servers load at launch,",
|
|
166
157
|
);
|
|
158
|
+
console.log("not per-tab). To finish setup:");
|
|
167
159
|
console.log("");
|
|
168
|
-
console.log(
|
|
169
|
-
|
|
170
|
-
);
|
|
171
|
-
console.log(" **2. Reopen Claude.**");
|
|
172
|
-
console.log(
|
|
173
|
-
' **3. In the message box, type: Set me up on social-autoposter. and send it.**',
|
|
174
|
-
);
|
|
175
|
-
console.log("");
|
|
176
|
-
console.log(
|
|
177
|
-
"Do not paraphrase those three lines, do not add commentary after them, and do",
|
|
178
|
-
);
|
|
179
|
-
console.log(
|
|
180
|
-
"not skip the bold. They are the only way the user can start the autoposter.",
|
|
181
|
-
);
|
|
160
|
+
console.log(" 1. Fully quit Claude (Cmd+Q; closing the window is not enough).");
|
|
161
|
+
console.log(" 2. Reopen Claude.");
|
|
162
|
+
console.log(' 3. Send: "Set me up on social-autoposter."');
|
|
182
163
|
}
|
|
183
164
|
} else {
|
|
184
165
|
console.error(
|
package/package.json
CHANGED
package/requirements.txt
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# social-autoposter Python deps.
|
|
2
|
-
# Installed by bin/cli.js
|
|
3
|
-
#
|
|
4
|
-
#
|
|
2
|
+
# Installed by bin/cli.js on init/update into the SAPS_PYTHON interpreter
|
|
3
|
+
# (Homebrew python), via `<saps_python> -m pip install -r requirements.txt`.
|
|
4
|
+
# Playwright browser binaries are downloaded separately (same interpreter:
|
|
5
|
+
# `<saps_python> -m playwright install chromium`).
|
|
5
6
|
|
|
6
7
|
psycopg2-binary
|
|
7
8
|
playwright
|
|
@@ -14,9 +14,10 @@ Flow:
|
|
|
14
14
|
`[SEO #<id>]` in the subject by default.
|
|
15
15
|
3. This script polls i@m13v.com for `is:unread subject:"Re: [SEO #"`.
|
|
16
16
|
For each match it: extracts the escalation id, strips quoted history
|
|
17
|
-
from the body, and
|
|
18
|
-
|
|
19
|
-
id=N AND status='pending'.
|
|
17
|
+
from the body, and PATCHes /api/v1/seo/escalations
|
|
18
|
+
(action=ingest_reply) which sets status='replied', human_reply,
|
|
19
|
+
replied_at=NOW(), gmail_inbound_id WHERE id=N AND status='pending'.
|
|
20
|
+
All state goes through the s4l.ai HTTP API (HTTP-only, no DATABASE_URL).
|
|
20
21
|
4. Marks the Gmail message as read so it is not re-ingested.
|
|
21
22
|
5. seo/resume_escalations.py (run from cron_seo.sh) picks up rows with
|
|
22
23
|
status='replied' and re-invokes generate_page.py --resume-escalation N.
|
|
@@ -37,8 +38,10 @@ from pathlib import Path
|
|
|
37
38
|
|
|
38
39
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
39
40
|
SEO_DIR = SCRIPT_DIR.parent / "seo"
|
|
40
|
-
sys.path.insert(0, str(
|
|
41
|
-
import
|
|
41
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
42
|
+
from http_api import api_get, api_patch, load_env # noqa: E402
|
|
43
|
+
|
|
44
|
+
load_env()
|
|
42
45
|
|
|
43
46
|
from google.auth.transport.requests import Request
|
|
44
47
|
from google.oauth2.credentials import Credentials
|
|
@@ -175,9 +178,6 @@ def main():
|
|
|
175
178
|
print("No candidate Gmail messages for SEO escalation replies.")
|
|
176
179
|
return
|
|
177
180
|
|
|
178
|
-
conn = db_helpers.get_conn()
|
|
179
|
-
cur = conn.cursor()
|
|
180
|
-
|
|
181
181
|
ingested = 0
|
|
182
182
|
skipped = 0
|
|
183
183
|
for c in candidates:
|
|
@@ -205,19 +205,20 @@ def main():
|
|
|
205
205
|
skipped += 1
|
|
206
206
|
continue
|
|
207
207
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
(escalation_id,),
|
|
212
|
-
)
|
|
213
|
-
esc_row = cur.fetchone()
|
|
208
|
+
show = api_get("/api/v1/seo/escalations",
|
|
209
|
+
query={"mode": "show", "id": escalation_id}, ok_on_404=True)
|
|
210
|
+
esc_row = None if show.get("_not_found") else (show.get("data") or {}).get("row")
|
|
214
211
|
if not esc_row:
|
|
215
212
|
print(f" SKIP {gmail_id}: escalation #{escalation_id} not found")
|
|
216
213
|
skipped += 1
|
|
217
214
|
mark_read(service, gmail_id) if not args.dry_run else None
|
|
218
215
|
continue
|
|
219
216
|
|
|
220
|
-
eid
|
|
217
|
+
eid = esc_row.get("id")
|
|
218
|
+
status = esc_row.get("status")
|
|
219
|
+
product = esc_row.get("product")
|
|
220
|
+
keyword = esc_row.get("keyword")
|
|
221
|
+
existing_inbound = esc_row.get("gmail_inbound_id")
|
|
221
222
|
|
|
222
223
|
# Idempotency: if the row already has an inbound id (already ingested),
|
|
223
224
|
# just mark Gmail as read and move on. If status is not pending, we
|
|
@@ -249,27 +250,19 @@ def main():
|
|
|
249
250
|
continue
|
|
250
251
|
|
|
251
252
|
try:
|
|
252
|
-
|
|
253
|
-
""
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
WHERE id = %s AND status = 'pending'
|
|
261
|
-
""",
|
|
262
|
-
(reply_text, gmail_id, eid),
|
|
263
|
-
)
|
|
264
|
-
if cur.rowcount == 0:
|
|
253
|
+
presp = api_patch("/api/v1/seo/escalations", {
|
|
254
|
+
"id": eid,
|
|
255
|
+
"action": "ingest_reply",
|
|
256
|
+
"human_reply": reply_text,
|
|
257
|
+
"gmail_inbound_id": gmail_id,
|
|
258
|
+
})
|
|
259
|
+
updated = int((presp.get("data") or {}).get("updated") or 0)
|
|
260
|
+
if updated == 0:
|
|
265
261
|
print(f" ERROR {gmail_id}: UPDATE matched 0 rows (race?); leaving message unread")
|
|
266
262
|
skipped += 1
|
|
267
|
-
conn.rollback()
|
|
268
263
|
continue
|
|
269
|
-
conn.commit()
|
|
270
264
|
except Exception as e:
|
|
271
265
|
print(f" ERROR {gmail_id}: update failed: {e}")
|
|
272
|
-
conn.rollback()
|
|
273
266
|
skipped += 1
|
|
274
267
|
continue
|
|
275
268
|
|
|
@@ -280,8 +273,6 @@ def main():
|
|
|
280
273
|
)
|
|
281
274
|
ingested += 1
|
|
282
275
|
|
|
283
|
-
cur.close()
|
|
284
|
-
conn.close()
|
|
285
276
|
print(f"Done. Ingested={ingested} skipped={skipped} candidates={len(candidates)}")
|
|
286
277
|
|
|
287
278
|
|
|
@@ -2072,7 +2072,14 @@ def main():
|
|
|
2072
2072
|
file=sys.stderr,
|
|
2073
2073
|
)
|
|
2074
2074
|
sys.exit(1)
|
|
2075
|
-
|
|
2075
|
+
# SAPS_SKIP_CAMPAIGN_SUFFIX=1 opts this reply out of active-campaign
|
|
2076
|
+
# suffixes (e.g. " written with ai"). Set ONLY by the MCP draft_cycle
|
|
2077
|
+
# post path (mcp/src/index.ts::postApproved) so manual/reviewed posts
|
|
2078
|
+
# land clean; the cron pipeline never sets it, so the A/B experiment
|
|
2079
|
+
# keeps running there and on Reddit. Reuses the existing apply_campaigns
|
|
2080
|
+
# plumbing (same flag the self-reply path uses below).
|
|
2081
|
+
_skip_camp = os.environ.get("SAPS_SKIP_CAMPAIGN_SUFFIX", "").strip().lower() in ("1", "true", "yes")
|
|
2082
|
+
result = reply_to_tweet(sys.argv[2], sys.argv[3], apply_campaigns=not _skip_camp)
|
|
2076
2083
|
print(json.dumps(result, indent=2))
|
|
2077
2084
|
|
|
2078
2085
|
elif cmd == "like":
|