@m13v/s4l 1.7.6 → 1.7.7-rc.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/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_browser_foreground.py +49 -0
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/_pa_compact.py +15 -0
- package/scripts/_pa_q.py +14 -0
- package/scripts/_pa_run.py +20 -0
- package/scripts/linkedin_pacing.py +400 -0
- package/scripts/linkedin_session_watch.py +196 -0
- package/scripts/post_reddit.py +1 -1
- package/scripts/reddit_ban_check.py +16 -1
- package/scripts/tlh_freshness_284.py +134 -0
- package/skill/engage-linkedin.sh +57 -1
- package/skill/run-linkedin.sh +24 -0
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.7.
|
|
5
|
+
"version": "1.7.7-rc.2",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
|
|
8
8
|
"author": {
|
|
@@ -79,6 +79,7 @@ class _Worker(threading.Thread):
|
|
|
79
79
|
super().__init__(daemon=True, name="s4l-browser-foreground")
|
|
80
80
|
self._pid_cache = {} # pid -> (is_harness, details dict)
|
|
81
81
|
self._prev_app = None # last non-harness frontmost app name
|
|
82
|
+
self._prev_app_pid = None # ...and its pid, for precise re-activation
|
|
82
83
|
self._last_key = None # (cause, pid) of last emitted event
|
|
83
84
|
self._last_emit_at = 0.0
|
|
84
85
|
self._suppressed = 0
|
|
@@ -108,13 +109,19 @@ class _Worker(threading.Thread):
|
|
|
108
109
|
if "chrome" not in low and "chromium" not in low:
|
|
109
110
|
if cause == "activated" and name:
|
|
110
111
|
self._prev_app = name
|
|
112
|
+
self._prev_app_pid = pid
|
|
111
113
|
return
|
|
112
114
|
is_harness, details = self._classify(pid)
|
|
113
115
|
if not is_harness:
|
|
114
116
|
# The user's own Chrome counts as their workspace too.
|
|
115
117
|
if cause == "activated" and name:
|
|
116
118
|
self._prev_app = name
|
|
119
|
+
self._prev_app_pid = pid
|
|
117
120
|
return
|
|
121
|
+
# Suppress focus on EVERY harness activation, BEFORE the telemetry
|
|
122
|
+
# dedupe — the log is rate-limited, but hiding must fire every time or
|
|
123
|
+
# a fast pop-burst would leave later pops sitting in the foreground.
|
|
124
|
+
self._suppress_focus(pid, details)
|
|
118
125
|
now = time.time()
|
|
119
126
|
key = (cause, pid)
|
|
120
127
|
if key == self._last_key and now - self._last_emit_at < _DEDUPE_SECONDS:
|
|
@@ -137,6 +144,48 @@ class _Worker(threading.Thread):
|
|
|
137
144
|
context="browser-foreground",
|
|
138
145
|
)
|
|
139
146
|
|
|
147
|
+
def _suppress_focus(self, pid, details):
|
|
148
|
+
# OS-LEVEL FOCUS SUPPRESSION (2026-07-30). The per-path code fixes
|
|
149
|
+
# (bh-harness activate suppression, tab reuse/park) each cover ONE way
|
|
150
|
+
# of driving Chrome; the reddit pipeline reaches it via a DIFFERENT
|
|
151
|
+
# path (reddit_browser.py Playwright connect/new_page) that bypasses
|
|
152
|
+
# them, and a raw external Playwright connect bypasses everything. This
|
|
153
|
+
# acts on the OS window event itself, so it covers EVERY connector:
|
|
154
|
+
# when an OFFSCREEN automation harness (window parked at negative Y —
|
|
155
|
+
# twitter/reddit/linkedin; NOT the onscreen setup-login window) grabs
|
|
156
|
+
# the foreground, hide that specific Chrome process by pid, then force
|
|
157
|
+
# focus back to the app the user was in. Screenshots/clicks are
|
|
158
|
+
# unaffected (CDP is offscreen-raster + synthetic input; the occlusion
|
|
159
|
+
# flags keep hidden tabs painting). Escape hatch: S4L_NO_HARNESS_HIDE.
|
|
160
|
+
if os.environ.get("S4L_NO_HARNESS_HIDE"):
|
|
161
|
+
return
|
|
162
|
+
pos = details.get("window_position") or ""
|
|
163
|
+
try:
|
|
164
|
+
offscreen = any(int(float(v)) < 0 for v in pos.split(",")[:2])
|
|
165
|
+
except Exception:
|
|
166
|
+
offscreen = False
|
|
167
|
+
if not offscreen:
|
|
168
|
+
return # onscreen (e.g. setup login) — leave it visible
|
|
169
|
+
try:
|
|
170
|
+
from AppKit import NSRunningApplication
|
|
171
|
+
|
|
172
|
+
ra = NSRunningApplication.runningApplicationWithProcessIdentifier_(pid)
|
|
173
|
+
if ra is not None:
|
|
174
|
+
ra.hide() # order the harness window out
|
|
175
|
+
# hide() alone does NOT reliably return focus here: all three
|
|
176
|
+
# harnesses share the "Google Chrome Beta" bundle, so hiding one
|
|
177
|
+
# process can leave the app-level active state on a sibling. Force
|
|
178
|
+
# focus back to the app the user was actually in.
|
|
179
|
+
if self._prev_app_pid and self._prev_app_pid != pid:
|
|
180
|
+
prev = NSRunningApplication.runningApplicationWithProcessIdentifier_(
|
|
181
|
+
self._prev_app_pid
|
|
182
|
+
)
|
|
183
|
+
# NSApplicationActivateIgnoringOtherApps = 1 << 1
|
|
184
|
+
if prev is not None and not prev.isTerminated():
|
|
185
|
+
prev.activateWithOptions_(1 << 1)
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
|
|
140
189
|
def run(self):
|
|
141
190
|
while True:
|
|
142
191
|
try:
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.7-rc.2",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import json,sys,subprocess,os
|
|
2
|
+
q=sys.argv[1]
|
|
3
|
+
env=dict(os.environ); env["SOCIAL_AUTOPOSTER_LINKEDIN_SEARCH"]="1"
|
|
4
|
+
p=subprocess.run(["/opt/homebrew/bin/python3.11","scripts/discover_linkedin_candidates.py","content",q],capture_output=True,text=True,env=env)
|
|
5
|
+
out=p.stdout
|
|
6
|
+
i=out.find("{")
|
|
7
|
+
try: d=json.loads(out[i:])
|
|
8
|
+
except Exception:
|
|
9
|
+
print(json.dumps({"query":q,"ok":False,"error":"parse_fail","tail":out[-300:]})); sys.exit()
|
|
10
|
+
if not d.get("ok"):
|
|
11
|
+
print(json.dumps({"query":q,"ok":False,"error":d.get("error"),"result_count":0})); sys.exit()
|
|
12
|
+
rows=[]
|
|
13
|
+
for r in d.get("results",[])[:6]:
|
|
14
|
+
rows.append({"an":r.get("author_name"),"hl":(r.get("author_headline") or "")[:90],"pu":r.get("author_profile_url"),"aid":r.get("activity_id"),"ah":r.get("age_hours"),"rx":r.get("reactions"),"cm":r.get("comments"),"rp":r.get("reposts"),"vs":r.get("velocity_score"),"tx":(r.get("post_text") or "")[:260]})
|
|
15
|
+
print(json.dumps({"query":q,"ok":True,"result_count":d.get("result_count"),"top":rows},ensure_ascii=False))
|
package/scripts/_pa_q.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import json,subprocess,sys,os
|
|
2
|
+
q=sys.argv[1]
|
|
3
|
+
env=dict(os.environ); env["SOCIAL_AUTOPOSTER_LINKEDIN_SEARCH"]="1"
|
|
4
|
+
p=subprocess.run(["/opt/homebrew/bin/python3.11","scripts/discover_linkedin_candidates.py","content",q],capture_output=True,text=True,env=env)
|
|
5
|
+
out=p.stdout
|
|
6
|
+
i=out.find("{")
|
|
7
|
+
try: d=json.loads(out[i:])
|
|
8
|
+
except Exception as e:
|
|
9
|
+
print(json.dumps({"query":q,"ok":False,"error":"parse","raw":out[-500:]})); sys.exit()
|
|
10
|
+
if not d.get("ok"):
|
|
11
|
+
print(json.dumps({"query":q,"ok":False,"error":d.get("error"),"result_count":0})); sys.exit()
|
|
12
|
+
res=d.get("results",[])
|
|
13
|
+
slim=[{"n":r.get("author_name"),"h":(r.get("author_headline") or "")[:110],"t":(r.get("post_text") or "")[:260],"a":round(r.get("age_hours") or 0,1),"rx":r.get("reactions"),"c":r.get("comments"),"rp":r.get("reposts"),"aid":r.get("activity_id"),"u":r.get("author_profile_url")} for r in res[:8]]
|
|
14
|
+
print(json.dumps({"query":q,"ok":True,"result_count":d.get("result_count"),"top":slim},ensure_ascii=False))
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import json,subprocess,sys,os
|
|
2
|
+
q=sys.argv[1]
|
|
3
|
+
env=dict(os.environ); env["SOCIAL_AUTOPOSTER_LINKEDIN_SEARCH"]="1"
|
|
4
|
+
p=subprocess.run(["/opt/homebrew/bin/python3.11","scripts/discover_linkedin_candidates.py","content",q],capture_output=True,text=True,env=env)
|
|
5
|
+
out=p.stdout
|
|
6
|
+
i=out.find("{")
|
|
7
|
+
try:
|
|
8
|
+
d=json.loads(out[i:])
|
|
9
|
+
except Exception:
|
|
10
|
+
print("PARSE_FAIL"); print(out[-1500:]); sys.exit(0)
|
|
11
|
+
if not d.get("ok"):
|
|
12
|
+
print("NOTOK",d.get("error"),d.get("retry_after_seconds")); sys.exit(0)
|
|
13
|
+
json.dump(d,open("/tmp/pa_%s.json"%q.replace(" ","_"),"w"))
|
|
14
|
+
print("query=",q,"result_count=",d.get("result_count"))
|
|
15
|
+
for r in d["results"][:6]:
|
|
16
|
+
print("---")
|
|
17
|
+
print(" auth:",r["author_name"],"|",(r.get("author_headline") or "")[:110])
|
|
18
|
+
print(" url:",r.get("post_url"),"aid:",r.get("activity_id"))
|
|
19
|
+
print(" age:",r.get("age_hours"),"rx:",r["reactions"],"cm:",r["comments"],"rp:",r["reposts"])
|
|
20
|
+
print(" txt:",(r.get("post_text") or "")[:260].replace("\n"," "))
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""LinkedIn action pacing gate.
|
|
3
|
+
|
|
4
|
+
Single source of truth for "is it safe to post another LinkedIn comment right
|
|
5
|
+
now, or should we wait / stop for today". Every LinkedIn write path calls
|
|
6
|
+
`check` immediately BEFORE each comment and honours the answer.
|
|
7
|
+
|
|
8
|
+
Why this exists
|
|
9
|
+
---------------
|
|
10
|
+
Until 2026-07-29 the LinkedIn write path had NO pacing control at all: no
|
|
11
|
+
minimum gap, no jitter, no hourly cap, no per-day cap. The human-looking cadence
|
|
12
|
+
on healthy days was an accident of queue availability and lock contention, not a
|
|
13
|
+
control. Observed inter-action gaps routinely bottomed out at 0-3 SECONDS.
|
|
14
|
+
|
|
15
|
+
TWO ACTION STREAMS - counting only one is the trap
|
|
16
|
+
--------------------------------------------------
|
|
17
|
+
LinkedIn writes land in two different tables and BOTH must be counted:
|
|
18
|
+
|
|
19
|
+
replies (replied_at) engage-linkedin.sh - replying to comments on our posts
|
|
20
|
+
posts (posted_at) run-linkedin.sh - commenting on others' posts,
|
|
21
|
+
via log_post.py, status='active'
|
|
22
|
+
|
|
23
|
+
The first cut of this gate counted only `replies` and therefore saw ~25% of
|
|
24
|
+
reality: it read 2026-07-20 as 23 actions when the true figure was 90, and read
|
|
25
|
+
2026-07-19 as ZERO when it was 73. Any future edit that narrows this query to a
|
|
26
|
+
single table silently disables most of the gate. Only rows with status='active'
|
|
27
|
+
count as real writes; log_post.py also records rejected candidates.
|
|
28
|
+
|
|
29
|
+
What the data does and does NOT support
|
|
30
|
+
---------------------------------------
|
|
31
|
+
Combined-stream daily figures, 2026-07-06..07-20:
|
|
32
|
+
|
|
33
|
+
n/day 65 .. 96 actions
|
|
34
|
+
per hour 2.9 .. 4.4
|
|
35
|
+
CV 0.32 .. 1.26 on EVERY day, healthy or not
|
|
36
|
+
|
|
37
|
+
An earlier draft of this file claimed CV (sd/mean of inter-action gaps)
|
|
38
|
+
separated logout days from healthy ones at ~1.0. That was an artifact of the
|
|
39
|
+
replies-only subsample. On the full stream it does NOT separate: 2026-07-14 ran
|
|
40
|
+
96 actions at CV 0.72 with no logout, and 2026-07-20 ran 90 at CV 0.78 and was
|
|
41
|
+
killed. We therefore do NOT rely on CV as a predictor; it is retained only at a
|
|
42
|
+
very low floor to catch a true metronome, which remains bad regardless of
|
|
43
|
+
whether it predicts a logout.
|
|
44
|
+
|
|
45
|
+
Stated plainly: we have NO statistic that reliably separates logout days from
|
|
46
|
+
healthy days. What we do know is (a) this account is flagged, in LinkedIn's own
|
|
47
|
+
words ("temporary restriction for automated activity" twice, "automation tool
|
|
48
|
+
detected" once), (b) every active stretch so far has ended in a session kill,
|
|
49
|
+
6 for 6, and (c) 65-96 automated actions a day with sub-second minimum gaps is
|
|
50
|
+
indefensible in absolute terms whatever the trigger turns out to be. The
|
|
51
|
+
ceilings below are therefore a deliberate ~70% volume cut chosen on judgment,
|
|
52
|
+
NOT a proven safe operating point. Revise them as evidence accumulates.
|
|
53
|
+
|
|
54
|
+
Ceilings (env-overridable, see CONFIG below):
|
|
55
|
+
min gap 120s hard floor; observed minimum was 0-3s
|
|
56
|
+
per rolling 1h 4 observed average was 2.9-4.4/h with bursts
|
|
57
|
+
per rolling 24h 25 observed 65-96/day
|
|
58
|
+
per rolling 72h 60 observed ~230/3d
|
|
59
|
+
CV floor 0.25 true-metronome catch only; NOT a logout predictor
|
|
60
|
+
min daily spread 4h actions must not bunch into one short window
|
|
61
|
+
|
|
62
|
+
CLI
|
|
63
|
+
---
|
|
64
|
+
linkedin_pacing.py check # exit 0 = post now, 75 = wait, 78 = stop
|
|
65
|
+
linkedin_pacing.py check --json # machine-readable decision
|
|
66
|
+
linkedin_pacing.py status # current counters, always exit 0
|
|
67
|
+
|
|
68
|
+
Exit codes are distinct so a shell caller can tell "wait" from "stop for now":
|
|
69
|
+
0 -> allowed, post now
|
|
70
|
+
75 -> not yet; sleep `wait_seconds` then re-check (EX_TEMPFAIL-ish)
|
|
71
|
+
78 -> a ceiling is blown; do not post at all this run (matches the repo's
|
|
72
|
+
existing rc=78 "skip this fire" convention)
|
|
73
|
+
|
|
74
|
+
FAIL-CLOSED: if the database cannot be read we deny (rc=78). The account is
|
|
75
|
+
already flagged; posting blind is worse than skipping a cycle. This mirrors the
|
|
76
|
+
existing "db_unavailable -> script already fails closed" rule in run-linkedin.sh.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
import argparse
|
|
80
|
+
import json
|
|
81
|
+
import math
|
|
82
|
+
import os
|
|
83
|
+
import random
|
|
84
|
+
import statistics
|
|
85
|
+
import sys
|
|
86
|
+
from datetime import datetime, timedelta, timezone
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --------------------------------------------------------------------------
|
|
90
|
+
# CONFIG - every value env-overridable so we can tune without editing a frozen
|
|
91
|
+
# file, and so tests can drive it against throwaway numbers.
|
|
92
|
+
# --------------------------------------------------------------------------
|
|
93
|
+
def _envf(name, default):
|
|
94
|
+
try:
|
|
95
|
+
return float(os.environ[name])
|
|
96
|
+
except (KeyError, ValueError):
|
|
97
|
+
return default
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _envi(name, default):
|
|
101
|
+
try:
|
|
102
|
+
return int(os.environ[name])
|
|
103
|
+
except (KeyError, ValueError):
|
|
104
|
+
return default
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --- interval GENERATION (not just a ceiling) ------------------------------
|
|
108
|
+
# A pure floor+ceiling cap produces a clipped, bimodal shape: run flat out at
|
|
109
|
+
# the floor until the hourly ceiling bites, then stall. Measured on the first
|
|
110
|
+
# version of this file: 59% of all gaps sat exactly at the 120s floor and NOTHING
|
|
111
|
+
# landed in the 3-10 minute band. That is a sharper machine signature than the
|
|
112
|
+
# unpaced behaviour it replaced, so the floor alone is not pacing.
|
|
113
|
+
#
|
|
114
|
+
# Instead we model arrivals as a POISSON PROCESS: each gap is drawn from an
|
|
115
|
+
# exponential distribution whose mean is set so the daily budget spreads across
|
|
116
|
+
# the active window. Exponential arrivals are memoryless, naturally varied
|
|
117
|
+
# (CV ~= 1.0 by construction), and produce no pile-up at any particular value.
|
|
118
|
+
# MIN_GAP_S survives only as a clamp for the short tail.
|
|
119
|
+
#
|
|
120
|
+
# The draw is DETERMINISTIC in the timestamp of the previous action. This is
|
|
121
|
+
# essential: the gate gets polled repeatedly while waiting, and a fresh random
|
|
122
|
+
# draw per call would let the caller "reroll until lucky", collapsing the whole
|
|
123
|
+
# distribution back onto the floor.
|
|
124
|
+
ACTIVE_START_H = _envi("LI_PACE_ACTIVE_START_H", 8) # local hour, inclusive
|
|
125
|
+
ACTIVE_END_H = _envi("LI_PACE_ACTIVE_END_H", 22) # local hour, exclusive
|
|
126
|
+
MAX_GAP_S = _envi("LI_PACE_MAX_GAP_S", 4 * 3600)
|
|
127
|
+
MIN_GAP_S = _envi("LI_PACE_MIN_GAP_S", 120)
|
|
128
|
+
CV_FLOOR = _envf("LI_PACE_CV_FLOOR", 0.25)
|
|
129
|
+
CV_WINDOW = _envi("LI_PACE_CV_WINDOW", 10)
|
|
130
|
+
MAX_PER_1H = _envi("LI_PACE_MAX_1H", 4)
|
|
131
|
+
MAX_PER_24H = _envi("LI_PACE_MAX_24H", 25)
|
|
132
|
+
MAX_PER_72H = _envi("LI_PACE_MAX_72H", 60)
|
|
133
|
+
MIN_SPREAD_S = _envi("LI_PACE_MIN_SPREAD_S", 4 * 3600)
|
|
134
|
+
# When CV is too low we do not just wait the floor, we inject a long randomized
|
|
135
|
+
# pause to actively break the metronome and pull CV back up.
|
|
136
|
+
CV_PAUSE_MIN_S = _envi("LI_PACE_CV_PAUSE_MIN_S", 900)
|
|
137
|
+
CV_PAUSE_MAX_S = _envi("LI_PACE_CV_PAUSE_MAX_S", 3600)
|
|
138
|
+
|
|
139
|
+
PLATFORM = "linkedin"
|
|
140
|
+
|
|
141
|
+
RC_ALLOW = 0
|
|
142
|
+
RC_WAIT = 75
|
|
143
|
+
RC_STOP = 78
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _database_url():
|
|
147
|
+
url = os.environ.get("DATABASE_URL")
|
|
148
|
+
if url:
|
|
149
|
+
return url.strip().strip('"').strip("'")
|
|
150
|
+
env_path = os.path.expanduser("~/social-autoposter/.env")
|
|
151
|
+
try:
|
|
152
|
+
with open(env_path, encoding="utf-8") as fh:
|
|
153
|
+
for line in fh:
|
|
154
|
+
line = line.strip()
|
|
155
|
+
if line.startswith("DATABASE_URL="):
|
|
156
|
+
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
|
157
|
+
except OSError:
|
|
158
|
+
pass
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _recent_timestamps(hours=72):
|
|
163
|
+
"""Every LinkedIn write action in the last `hours`, UTC, ascending.
|
|
164
|
+
|
|
165
|
+
UNION of BOTH write streams. See the module docstring: counting only one
|
|
166
|
+
table silently disables most of this gate.
|
|
167
|
+
|
|
168
|
+
Raises on any failure so callers can fail closed.
|
|
169
|
+
"""
|
|
170
|
+
import psycopg2 # imported lazily so `--help` works without the driver
|
|
171
|
+
|
|
172
|
+
url = _database_url()
|
|
173
|
+
if not url:
|
|
174
|
+
raise RuntimeError("DATABASE_URL not resolvable")
|
|
175
|
+
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
|
176
|
+
conn = psycopg2.connect(url, connect_timeout=10)
|
|
177
|
+
try:
|
|
178
|
+
with conn.cursor() as cur:
|
|
179
|
+
cur.execute(
|
|
180
|
+
"select ts from ("
|
|
181
|
+
" select posted_at as ts from posts"
|
|
182
|
+
" where platform = %s and status = 'active'"
|
|
183
|
+
" and posted_at is not null and posted_at >= %s"
|
|
184
|
+
" union all"
|
|
185
|
+
" select replied_at as ts from replies"
|
|
186
|
+
" where platform = %s"
|
|
187
|
+
" and replied_at is not null and replied_at >= %s"
|
|
188
|
+
") a order by ts",
|
|
189
|
+
(PLATFORM, since, PLATFORM, since),
|
|
190
|
+
)
|
|
191
|
+
return [r[0] for r in cur.fetchall()]
|
|
192
|
+
finally:
|
|
193
|
+
conn.close()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _active_seconds_per_day():
|
|
197
|
+
span = (ACTIVE_END_H - ACTIVE_START_H) % 24
|
|
198
|
+
return (span or 24) * 3600
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _in_active_window(dt_local):
|
|
202
|
+
h = dt_local.hour
|
|
203
|
+
if ACTIVE_START_H == ACTIVE_END_H:
|
|
204
|
+
return True
|
|
205
|
+
if ACTIVE_START_H < ACTIVE_END_H:
|
|
206
|
+
return ACTIVE_START_H <= h < ACTIVE_END_H
|
|
207
|
+
return h >= ACTIVE_START_H or h < ACTIVE_END_H # window wraps midnight
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _seconds_to_window_open(dt_local):
|
|
211
|
+
"""Seconds until the active window next opens. 0 if already open."""
|
|
212
|
+
if _in_active_window(dt_local):
|
|
213
|
+
return 0
|
|
214
|
+
nxt = dt_local.replace(hour=ACTIVE_START_H, minute=0, second=0, microsecond=0)
|
|
215
|
+
if nxt <= dt_local:
|
|
216
|
+
nxt += timedelta(days=1)
|
|
217
|
+
return (nxt - dt_local).total_seconds()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _target_gap(last_ts):
|
|
221
|
+
"""Exponentially-distributed target gap, deterministic in `last_ts`.
|
|
222
|
+
|
|
223
|
+
mean = active_seconds_per_day / MAX_PER_24H, so a full day's budget spreads
|
|
224
|
+
naturally across the active window instead of bunching at a floor.
|
|
225
|
+
|
|
226
|
+
Determinism matters: see the CONFIG note. Same last_ts always yields the
|
|
227
|
+
same target, so polling cannot reroll its way down to MIN_GAP_S.
|
|
228
|
+
"""
|
|
229
|
+
import hashlib
|
|
230
|
+
|
|
231
|
+
mean = _active_seconds_per_day() / max(MAX_PER_24H, 1)
|
|
232
|
+
digest = hashlib.sha256(last_ts.isoformat().encode("utf-8")).digest()
|
|
233
|
+
# 53 bits -> uniform in [0,1); nudged off the endpoints for the log below.
|
|
234
|
+
u = int.from_bytes(digest[:7], "big") / float(1 << 56)
|
|
235
|
+
u = min(max(u, 1e-9), 1 - 1e-9)
|
|
236
|
+
gap = -mean * math.log(1.0 - u) # inverse-CDF of Exponential(1/mean)
|
|
237
|
+
return max(MIN_GAP_S, min(gap, MAX_GAP_S))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _gaps(ts):
|
|
241
|
+
return [
|
|
242
|
+
(b - a).total_seconds()
|
|
243
|
+
for a, b in zip(ts, ts[1:])
|
|
244
|
+
if (b - a).total_seconds() >= 0
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _cv(gaps):
|
|
249
|
+
"""Coefficient of variation. None when undefined (need >= 2 gaps)."""
|
|
250
|
+
if len(gaps) < 2:
|
|
251
|
+
return None
|
|
252
|
+
mean = statistics.fmean(gaps)
|
|
253
|
+
if mean <= 0:
|
|
254
|
+
return 0.0
|
|
255
|
+
return statistics.stdev(gaps) / mean
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def evaluate(now=None, timestamps=None):
|
|
259
|
+
"""Return a decision dict. Pure given its inputs, so it is testable."""
|
|
260
|
+
now = now or datetime.now(timezone.utc)
|
|
261
|
+
ts = timestamps if timestamps is not None else _recent_timestamps()
|
|
262
|
+
|
|
263
|
+
in_1h = [t for t in ts if t >= now - timedelta(hours=1)]
|
|
264
|
+
in_24h = [t for t in ts if t >= now - timedelta(hours=24)]
|
|
265
|
+
in_72h = ts
|
|
266
|
+
|
|
267
|
+
counters = {
|
|
268
|
+
"count_1h": len(in_1h),
|
|
269
|
+
"count_24h": len(in_24h),
|
|
270
|
+
"count_72h": len(in_72h),
|
|
271
|
+
"max_1h": MAX_PER_1H,
|
|
272
|
+
"max_24h": MAX_PER_24H,
|
|
273
|
+
"max_72h": MAX_PER_72H,
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
def decision(action, rc, reason, wait_seconds=0, **extra):
|
|
277
|
+
d = {
|
|
278
|
+
"action": action,
|
|
279
|
+
"rc": rc,
|
|
280
|
+
"reason": reason,
|
|
281
|
+
"wait_seconds": int(wait_seconds),
|
|
282
|
+
"checked_at": now.isoformat(),
|
|
283
|
+
}
|
|
284
|
+
d.update(counters)
|
|
285
|
+
d.update(extra)
|
|
286
|
+
return d
|
|
287
|
+
|
|
288
|
+
# ---- hard ceilings first: these mean "stop", not "wait a bit" ----------
|
|
289
|
+
if len(in_24h) >= MAX_PER_24H:
|
|
290
|
+
return decision("stop", RC_STOP,
|
|
291
|
+
f"24h cap reached ({len(in_24h)}/{MAX_PER_24H})")
|
|
292
|
+
if len(in_72h) >= MAX_PER_72H:
|
|
293
|
+
return decision("stop", RC_STOP,
|
|
294
|
+
f"72h cap reached ({len(in_72h)}/{MAX_PER_72H})")
|
|
295
|
+
|
|
296
|
+
# ---- rolling hour: a wait, since it clears on its own ------------------
|
|
297
|
+
if len(in_1h) >= MAX_PER_1H:
|
|
298
|
+
oldest = min(in_1h)
|
|
299
|
+
wait = (oldest + timedelta(hours=1) - now).total_seconds()
|
|
300
|
+
return decision("wait", RC_WAIT,
|
|
301
|
+
f"1h cap reached ({len(in_1h)}/{MAX_PER_1H})",
|
|
302
|
+
max(wait, 60))
|
|
303
|
+
|
|
304
|
+
# ---- active-hours window ----------------------------------------------
|
|
305
|
+
# Humans do not comment uniformly around the clock. Observed spans were
|
|
306
|
+
# 20-24h/day, which is itself a tell independent of volume.
|
|
307
|
+
now_local = now.astimezone()
|
|
308
|
+
to_open = _seconds_to_window_open(now_local)
|
|
309
|
+
if to_open > 0:
|
|
310
|
+
return decision("wait", RC_WAIT,
|
|
311
|
+
f"outside active window "
|
|
312
|
+
f"{ACTIVE_START_H:02d}:00-{ACTIVE_END_H:02d}:00 local",
|
|
313
|
+
to_open)
|
|
314
|
+
|
|
315
|
+
# ---- sampled inter-action interval (the actual pacing) ----------------
|
|
316
|
+
# Not a flat floor: a per-gap draw from an exponential, so the resulting
|
|
317
|
+
# distribution is continuous instead of piling up at a single value.
|
|
318
|
+
if ts:
|
|
319
|
+
last = max(ts)
|
|
320
|
+
since_last = (now - last).total_seconds()
|
|
321
|
+
target = _target_gap(last)
|
|
322
|
+
if since_last < target:
|
|
323
|
+
return decision("wait", RC_WAIT,
|
|
324
|
+
f"sampled interval not elapsed "
|
|
325
|
+
f"({int(since_last)}s < {int(target)}s target)",
|
|
326
|
+
target - since_last,
|
|
327
|
+
seconds_since_last=int(since_last),
|
|
328
|
+
target_gap_s=int(target))
|
|
329
|
+
|
|
330
|
+
# ---- cadence regularity -----------------------------------------------
|
|
331
|
+
# CRITICAL: both this rule and the spread rule below must be evaluated
|
|
332
|
+
# against the state that WOULD exist if we posted right now, never against
|
|
333
|
+
# history alone. History does not change while we wait, so a rule phrased
|
|
334
|
+
# over past gaps can never be satisfied by waiting: an earlier version of
|
|
335
|
+
# this file compared the last two gaps (120s, 120s -> CV 0.00), told the
|
|
336
|
+
# caller to sleep, and then returned the identical verdict forever. That
|
|
337
|
+
# livelocked the pipeline at 3 actions total and cost 1.3 actions/day
|
|
338
|
+
# instead of the intended ~25. Including the pending gap makes waiting
|
|
339
|
+
# monotonically increase CV, so the rule always resolves itself.
|
|
340
|
+
candidate_gap = (now - max(ts)).total_seconds() if ts else None
|
|
341
|
+
gaps = _gaps(ts)
|
|
342
|
+
if candidate_gap is not None:
|
|
343
|
+
gaps = gaps + [candidate_gap]
|
|
344
|
+
gaps = gaps[-CV_WINDOW:]
|
|
345
|
+
cv = _cv(gaps)
|
|
346
|
+
if cv is not None and cv < CV_FLOOR:
|
|
347
|
+
# Metronomic. Break it with a long randomized pause rather than the floor.
|
|
348
|
+
pause = random.uniform(CV_PAUSE_MIN_S, CV_PAUSE_MAX_S)
|
|
349
|
+
return decision("wait", RC_WAIT,
|
|
350
|
+
f"cadence too regular (CV={cv:.2f} < {CV_FLOOR:.2f} "
|
|
351
|
+
f"over last {len(gaps)} gaps incl. pending)",
|
|
352
|
+
pause, cv=round(cv, 3))
|
|
353
|
+
|
|
354
|
+
# ---- daily spread: do not bunch the day's actions into one window ------
|
|
355
|
+
# Same "as if we posted now" framing: `now` is the effective end of the
|
|
356
|
+
# window, so the spread grows as we wait and the rule cannot livelock.
|
|
357
|
+
if len(in_24h) >= max(3, MAX_PER_24H // 2):
|
|
358
|
+
spread = (now - min(in_24h)).total_seconds()
|
|
359
|
+
if spread < MIN_SPREAD_S:
|
|
360
|
+
wait = MIN_SPREAD_S - spread
|
|
361
|
+
return decision("wait", RC_WAIT,
|
|
362
|
+
f"daily spread too tight ({int(spread/60)}min over "
|
|
363
|
+
f"{len(in_24h)} actions; want >= {MIN_SPREAD_S//3600}h)",
|
|
364
|
+
wait, spread_minutes=int(spread / 60))
|
|
365
|
+
|
|
366
|
+
return decision("allow", RC_ALLOW, "within all pacing limits",
|
|
367
|
+
cv=(round(cv, 3) if cv is not None else None))
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def main():
|
|
371
|
+
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
372
|
+
ap.add_argument("command", choices=["check", "status"])
|
|
373
|
+
ap.add_argument("--json", action="store_true",
|
|
374
|
+
help="emit the full decision as JSON")
|
|
375
|
+
args = ap.parse_args()
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
d = evaluate()
|
|
379
|
+
except Exception as exc: # noqa: BLE001 - fail closed on ANY read failure
|
|
380
|
+
d = {
|
|
381
|
+
"action": "stop",
|
|
382
|
+
"rc": RC_STOP,
|
|
383
|
+
"reason": f"pacing state unreadable, failing closed: {exc}",
|
|
384
|
+
"wait_seconds": 0,
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if args.command == "status":
|
|
388
|
+
print(json.dumps(d, indent=2))
|
|
389
|
+
return 0
|
|
390
|
+
|
|
391
|
+
if args.json:
|
|
392
|
+
print(json.dumps(d))
|
|
393
|
+
else:
|
|
394
|
+
print(f"{d['action'].upper()}: {d['reason']}"
|
|
395
|
+
+ (f" (wait {d['wait_seconds']}s)" if d["wait_seconds"] else ""))
|
|
396
|
+
return d["rc"]
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
if __name__ == "__main__":
|
|
400
|
+
sys.exit(main())
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""LinkedIn session canary: detect a dropped session in ~60s instead of ~30min.
|
|
3
|
+
|
|
4
|
+
What problem this solves
|
|
5
|
+
------------------------
|
|
6
|
+
`detect-gate` only probes the session at the START of a pipeline run. On
|
|
7
|
+
2026-07-20 it reported "session healthy (feed renders)" at 20:27:31Z, the
|
|
8
|
+
session was killed somewhere around 20:50Z mid-comment, and nothing noticed
|
|
9
|
+
until the next run-linkedin fire at 20:57:12Z. That is a ~30 minute blind
|
|
10
|
+
window in which the pipeline believed it was logged in.
|
|
11
|
+
|
|
12
|
+
This canary closes that window by reading the `li_at` session cookie straight
|
|
13
|
+
out of the ALREADY-RUNNING harness Chrome over CDP. Two properties matter:
|
|
14
|
+
|
|
15
|
+
1. It costs ZERO LinkedIn traffic. CDP is a local debugging channel, so this
|
|
16
|
+
adds no request footprint to an account that is already flagged. That is
|
|
17
|
+
why we do not simply fetch /feed/ on a timer.
|
|
18
|
+
2. It reads the LIVE browser, not the on-disk Cookies sqlite file. The
|
|
19
|
+
on-disk store lags badly: on 2026-07-20 the `linkedin` profile's Cookies
|
|
20
|
+
db had an mtime of 2026-07-02 while the live context still held a valid
|
|
21
|
+
li_at. Reading the file would produce false alarms.
|
|
22
|
+
|
|
23
|
+
Conservative by design: if Chrome is down, or CDP does not answer, or the
|
|
24
|
+
cookie read fails, we report `unknown` and do NOTHING. A canary that cries wolf
|
|
25
|
+
gets ignored, and engaging the killswitch is disruptive (it halts nine launchd
|
|
26
|
+
jobs). We only act on a positive, well-formed "Chrome is up, cookies read fine,
|
|
27
|
+
li_at is absent".
|
|
28
|
+
|
|
29
|
+
CLI
|
|
30
|
+
---
|
|
31
|
+
linkedin_session_watch.py check # report only, never engages
|
|
32
|
+
linkedin_session_watch.py check --engage # engage killswitch if li_at gone
|
|
33
|
+
linkedin_session_watch.py check --json
|
|
34
|
+
|
|
35
|
+
Exit codes:
|
|
36
|
+
0 li_at present (healthy)
|
|
37
|
+
1 li_at absent (session dropped)
|
|
38
|
+
2 unknown / not checkable (Chrome down, CDP silent) - NOT an error
|
|
39
|
+
|
|
40
|
+
Intended use: launchd every 60s with --engage.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import argparse
|
|
44
|
+
import json
|
|
45
|
+
import os
|
|
46
|
+
import subprocess
|
|
47
|
+
import sys
|
|
48
|
+
import urllib.request
|
|
49
|
+
from datetime import datetime, timezone
|
|
50
|
+
|
|
51
|
+
CDP_URL = os.environ.get("LINKEDIN_CDP_URL", "http://127.0.0.1:9556")
|
|
52
|
+
REPO_DIR = os.path.expanduser("~/social-autoposter")
|
|
53
|
+
KILLSWITCH = os.path.join(REPO_DIR, "scripts", "linkedin_killswitch.py")
|
|
54
|
+
STATE_PATH = os.path.expanduser(
|
|
55
|
+
"~/.claude/social-autoposter/linkedin.session_canary.json"
|
|
56
|
+
)
|
|
57
|
+
TIMEOUT_S = 8
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _browser_ws_url():
|
|
61
|
+
"""Browser-level CDP websocket URL, or None if Chrome is not reachable."""
|
|
62
|
+
try:
|
|
63
|
+
with urllib.request.urlopen(f"{CDP_URL}/json/version", timeout=TIMEOUT_S) as r:
|
|
64
|
+
return json.loads(r.read().decode("utf-8")).get("webSocketDebuggerUrl")
|
|
65
|
+
except Exception:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _all_cookies(ws_url):
|
|
70
|
+
"""Storage.getCookies at browser scope. Returns list, or None on failure.
|
|
71
|
+
|
|
72
|
+
Uses websocket-client, which is present on the pipeline's /opt/homebrew/bin/python3.
|
|
73
|
+
We deliberately avoid playwright's connect_over_cdp here: this runs every
|
|
74
|
+
minute alongside live pipeline work, and a passive single-command socket is
|
|
75
|
+
far less likely to perturb a run than attaching a full automation client.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
import websocket # websocket-client
|
|
79
|
+
except ImportError:
|
|
80
|
+
return None
|
|
81
|
+
ws = None
|
|
82
|
+
try:
|
|
83
|
+
# suppress_origin is REQUIRED: websocket-client otherwise sends an
|
|
84
|
+
# Origin header and Chrome rejects the handshake with
|
|
85
|
+
# "403 Rejected an incoming WebSocket connection from the ... origin".
|
|
86
|
+
ws = websocket.create_connection(
|
|
87
|
+
ws_url, timeout=TIMEOUT_S, suppress_origin=True
|
|
88
|
+
)
|
|
89
|
+
ws.send(json.dumps({"id": 1, "method": "Storage.getCookies", "params": {}}))
|
|
90
|
+
# The browser endpoint can interleave events; read until our reply lands.
|
|
91
|
+
for _ in range(20):
|
|
92
|
+
msg = json.loads(ws.recv())
|
|
93
|
+
if msg.get("id") == 1:
|
|
94
|
+
if "error" in msg:
|
|
95
|
+
return None
|
|
96
|
+
return msg.get("result", {}).get("cookies", [])
|
|
97
|
+
return None
|
|
98
|
+
except Exception:
|
|
99
|
+
return None
|
|
100
|
+
finally:
|
|
101
|
+
if ws is not None:
|
|
102
|
+
try:
|
|
103
|
+
ws.close()
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def check():
|
|
109
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
110
|
+
ws_url = _browser_ws_url()
|
|
111
|
+
if not ws_url:
|
|
112
|
+
return {"status": "unknown", "reason": "harness Chrome not reachable on CDP",
|
|
113
|
+
"checked_at": now}
|
|
114
|
+
|
|
115
|
+
cookies = _all_cookies(ws_url)
|
|
116
|
+
if cookies is None:
|
|
117
|
+
return {"status": "unknown", "reason": "CDP cookie read failed",
|
|
118
|
+
"checked_at": now}
|
|
119
|
+
|
|
120
|
+
li = [c for c in cookies if "linkedin.com" in (c.get("domain") or "")]
|
|
121
|
+
has_li_at = any(c.get("name") == "li_at" for c in li)
|
|
122
|
+
has_li_rm = any(c.get("name") == "li_rm" for c in li)
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
"status": "healthy" if has_li_at else "dropped",
|
|
126
|
+
"reason": ("li_at present" if has_li_at
|
|
127
|
+
else "li_at absent from live harness Chrome"),
|
|
128
|
+
"linkedin_cookie_count": len(li),
|
|
129
|
+
"has_li_rm": has_li_rm,
|
|
130
|
+
"checked_at": now,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _killswitch_active():
|
|
135
|
+
try:
|
|
136
|
+
return subprocess.run(
|
|
137
|
+
["/opt/homebrew/bin/python3", KILLSWITCH, "check"],
|
|
138
|
+
capture_output=True, timeout=30,
|
|
139
|
+
).returncode != 0
|
|
140
|
+
except Exception:
|
|
141
|
+
return True # assume active; never double-engage on uncertainty
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def engage(detail):
|
|
145
|
+
"""Engage the killswitch. Idempotent upstream: the FIRST signal wins."""
|
|
146
|
+
if _killswitch_active():
|
|
147
|
+
return {"engaged": False, "note": "killswitch already active"}
|
|
148
|
+
try:
|
|
149
|
+
p = subprocess.run(
|
|
150
|
+
["/opt/homebrew/bin/python3", KILLSWITCH, "engage",
|
|
151
|
+
"--signal", "li_at_cleared", "--detail", detail],
|
|
152
|
+
capture_output=True, text=True, timeout=120,
|
|
153
|
+
)
|
|
154
|
+
return {"engaged": p.returncode == 0,
|
|
155
|
+
"stdout": (p.stdout or "").strip()[:300],
|
|
156
|
+
"stderr": (p.stderr or "").strip()[:300]}
|
|
157
|
+
except Exception as exc: # noqa: BLE001
|
|
158
|
+
return {"engaged": False, "error": str(exc)[:200]}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _write_state(result):
|
|
162
|
+
try:
|
|
163
|
+
os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True)
|
|
164
|
+
with open(STATE_PATH, "w", encoding="utf-8") as fh:
|
|
165
|
+
json.dump(result, fh, indent=2)
|
|
166
|
+
except OSError:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def main():
|
|
171
|
+
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
172
|
+
ap.add_argument("command", choices=["check"])
|
|
173
|
+
ap.add_argument("--engage", action="store_true",
|
|
174
|
+
help="engage the killswitch when li_at is absent")
|
|
175
|
+
ap.add_argument("--json", action="store_true")
|
|
176
|
+
args = ap.parse_args()
|
|
177
|
+
|
|
178
|
+
result = check()
|
|
179
|
+
|
|
180
|
+
if result["status"] == "dropped" and args.engage:
|
|
181
|
+
result["killswitch"] = engage(
|
|
182
|
+
"session canary: li_at absent from live harness Chrome"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
_write_state(result)
|
|
186
|
+
|
|
187
|
+
if args.json:
|
|
188
|
+
print(json.dumps(result))
|
|
189
|
+
else:
|
|
190
|
+
print(f"{result['status'].upper()}: {result['reason']}")
|
|
191
|
+
|
|
192
|
+
return {"healthy": 0, "dropped": 1, "unknown": 2}[result["status"]]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
if __name__ == "__main__":
|
|
196
|
+
sys.exit(main())
|
package/scripts/post_reddit.py
CHANGED
|
@@ -2879,7 +2879,7 @@ def main():
|
|
|
2879
2879
|
parser.add_argument("--batch-id", dest="batch_id", default=None,
|
|
2880
2880
|
help="Cycle-level batch_id (e.g. rdcycle-YYYYMMDD-HHMMSS). Used by "
|
|
2881
2881
|
"--phase phase0 / --phase salvage / --phase discover to attribute "
|
|
2882
|
-
"rows in reddit_candidates
|
|
2882
|
+
"rows in reddit_candidates. Required for "
|
|
2883
2883
|
"phase0 and salvage; optional for discover (defaults to a "
|
|
2884
2884
|
"per-discover synthetic id).")
|
|
2885
2885
|
args = parser.parse_args()
|
|
@@ -99,7 +99,22 @@ def _reddit_page(pw):
|
|
|
99
99
|
try:
|
|
100
100
|
browser = pw.chromium.connect_over_cdp(ws)
|
|
101
101
|
ctx = browser.contexts[0] if browser.contexts else browser.new_context()
|
|
102
|
-
|
|
102
|
+
# Reuse an existing reddit tab instead of new_page(). new_page() opens
|
|
103
|
+
# a fresh tab on the reddit homepage and STEALS OS FOCUS every call —
|
|
104
|
+
# this ran ~per-strike and was the reddit focus-pop the other reddit
|
|
105
|
+
# helpers (reddit_browser / reddit_browser_fetch) already fixed but
|
|
106
|
+
# this 2026-07-17 ban-check never did. Navigating a background tab does
|
|
107
|
+
# not pop. Prefer a tab already on reddit.com; else pages[0]; else
|
|
108
|
+
# create one. Left OPEN for reuse (cleanup_harness_tabs trims to one).
|
|
109
|
+
page = None
|
|
110
|
+
for pg in ctx.pages:
|
|
111
|
+
if "reddit.com" in (pg.url or "") and "login" not in (pg.url or ""):
|
|
112
|
+
page = pg
|
|
113
|
+
break
|
|
114
|
+
if page is None and ctx.pages:
|
|
115
|
+
page = ctx.pages[0]
|
|
116
|
+
if page is None:
|
|
117
|
+
page = ctx.new_page()
|
|
103
118
|
page.set_default_timeout(20000)
|
|
104
119
|
return browser, page
|
|
105
120
|
except Exception:
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import os, re, subprocess, hashlib, itertools, json
|
|
2
|
+
import psycopg2
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
|
|
5
|
+
REM = os.path.expanduser("~/social-autoposter/mixer/remotion")
|
|
6
|
+
PUB = os.path.join(REM, "public", "mixer")
|
|
7
|
+
FFPROBE = "ffprobe"
|
|
8
|
+
os.environ["PATH"] = "/opt/homebrew/Cellar/ffmpeg/8.1.1/bin:" + os.environ.get("PATH","")
|
|
9
|
+
|
|
10
|
+
def dburl():
|
|
11
|
+
with open(os.path.expanduser("~/social-autoposter/.env")) as f:
|
|
12
|
+
for line in f:
|
|
13
|
+
if line.startswith("DATABASE_URL="):
|
|
14
|
+
return line.split("=",1)[1].strip()
|
|
15
|
+
raise SystemExit("no DATABASE_URL")
|
|
16
|
+
|
|
17
|
+
def probe_dur(p):
|
|
18
|
+
try:
|
|
19
|
+
out = subprocess.check_output([FFPROBE,"-v","error","-show_entries","format=duration","-of","csv=p=0",p]).decode().strip()
|
|
20
|
+
return float(out)
|
|
21
|
+
except Exception:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
def md5(p):
|
|
25
|
+
h=hashlib.md5()
|
|
26
|
+
with open(p,"rb") as f:
|
|
27
|
+
for chunk in iter(lambda:f.read(1<<20), b""):
|
|
28
|
+
h.update(chunk)
|
|
29
|
+
return h.hexdigest()
|
|
30
|
+
|
|
31
|
+
# 1. all tlh-*.mp4 slot files, their duration + content md5
|
|
32
|
+
slots={}
|
|
33
|
+
for fn in os.listdir(PUB):
|
|
34
|
+
if fn.startswith("tlh-") and fn.endswith(".mp4"):
|
|
35
|
+
p=os.path.join(PUB,fn)
|
|
36
|
+
d=probe_dur(p)
|
|
37
|
+
slots[fn]={"dur":d,"md5":md5(p)}
|
|
38
|
+
|
|
39
|
+
# content class = md5. Map filename -> class
|
|
40
|
+
fn2class={fn:info["md5"] for fn,info in slots.items()}
|
|
41
|
+
# which classes are ~2.0s (target 2.0 slots). Consider a file 2.0s if 1.95<=dur<=2.05
|
|
42
|
+
two_sec_classes=set()
|
|
43
|
+
class_dur=defaultdict(list)
|
|
44
|
+
for fn,info in slots.items():
|
|
45
|
+
if info["dur"] is not None:
|
|
46
|
+
class_dur[info["md5"]].append(info["dur"])
|
|
47
|
+
for cls,durs in class_dur.items():
|
|
48
|
+
avg=sum(durs)/len(durs)
|
|
49
|
+
if 1.95<=avg<=2.05:
|
|
50
|
+
two_sec_classes.add(cls)
|
|
51
|
+
|
|
52
|
+
# 2. DB: all lesson-% rows, source_clips + post_number
|
|
53
|
+
conn=psycopg2.connect(dburl())
|
|
54
|
+
cur=conn.cursor()
|
|
55
|
+
cur.execute("""SELECT post_number, variant_id, source_clips, metadata->>'theme_angle'
|
|
56
|
+
FROM media_posts WHERE variant_id LIKE 'lesson-%' AND source_clips IS NOT NULL
|
|
57
|
+
ORDER BY post_number""")
|
|
58
|
+
rows=cur.fetchall()
|
|
59
|
+
|
|
60
|
+
# map each row -> set of content classes (from src basenames), record post ordering
|
|
61
|
+
variant_classes=[] # (post_number, variant_id, frozenset(classes))
|
|
62
|
+
class_last_post=defaultdict(lambda:-1)
|
|
63
|
+
for pn,vid,sc,angle in rows:
|
|
64
|
+
if isinstance(sc,str):
|
|
65
|
+
sc=json.loads(sc)
|
|
66
|
+
classes=set()
|
|
67
|
+
for c in sc:
|
|
68
|
+
src=c.get("src","")
|
|
69
|
+
base=os.path.basename(src)
|
|
70
|
+
cls=fn2class.get(base)
|
|
71
|
+
if cls:
|
|
72
|
+
classes.add(cls)
|
|
73
|
+
variant_classes.append((pn,vid,frozenset(classes)))
|
|
74
|
+
for cls in classes:
|
|
75
|
+
if pn>class_last_post[cls]:
|
|
76
|
+
class_last_post[cls]=pn
|
|
77
|
+
|
|
78
|
+
# recent organic renders = last 5 lesson rows by post_number
|
|
79
|
+
recent5=set()
|
|
80
|
+
for pn,vid,cls in sorted(variant_classes,key=lambda x:-x[0])[:5]:
|
|
81
|
+
recent5|=set(cls)
|
|
82
|
+
|
|
83
|
+
# candidate 2.0s classes, ranked by staleness (lowest last_post first)
|
|
84
|
+
cands=[(class_last_post[c],c) for c in two_sec_classes]
|
|
85
|
+
cands.sort() # stalest first
|
|
86
|
+
print("== 2.0s content classes, stalest first (last_post, class, example_files) ==")
|
|
87
|
+
class_files=defaultdict(list)
|
|
88
|
+
for fn,cls in fn2class.items():
|
|
89
|
+
class_files[cls].append(fn)
|
|
90
|
+
for lp,c in cands[:20]:
|
|
91
|
+
print(f" last_post={lp:>4} {c[:12]} files={sorted(class_files[c])[:4]}")
|
|
92
|
+
|
|
93
|
+
# no-3+-co-occurrence: chosen 4-set shares <=2 classes with ANY prior variant
|
|
94
|
+
def ok_set(fourset):
|
|
95
|
+
fs=set(fourset)
|
|
96
|
+
for pn,vid,cls in variant_classes:
|
|
97
|
+
if len(fs & set(cls))>=3:
|
|
98
|
+
return False
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
# not in recent5
|
|
102
|
+
pool=[c for lp,c in cands if c not in recent5]
|
|
103
|
+
print(f"\n== pool (stale, not in last-5) size={len(pool)} ==")
|
|
104
|
+
|
|
105
|
+
# greedily search: take combinations of the stalest, prefer overall stalest sum
|
|
106
|
+
chosen=None
|
|
107
|
+
# limit search to stalest ~14 for tractability
|
|
108
|
+
search=pool[:14]
|
|
109
|
+
best=None
|
|
110
|
+
for combo in itertools.combinations(search,4):
|
|
111
|
+
if ok_set(combo):
|
|
112
|
+
score=sum(class_last_post[c] for c in combo)
|
|
113
|
+
if best is None or score<best[0]:
|
|
114
|
+
best=(score,combo)
|
|
115
|
+
if best:
|
|
116
|
+
chosen=best[1]
|
|
117
|
+
print("\n== CHOSEN 4-set (min staleness score, no 3+ co-occurrence) ==")
|
|
118
|
+
for c in chosen:
|
|
119
|
+
print(f" last_post={class_last_post[c]:>4} {c[:12]} pick_file={sorted(class_files[c])[0]}")
|
|
120
|
+
# verify max pairwise co-occurrence
|
|
121
|
+
maxco=0
|
|
122
|
+
for pn,vid,cls in variant_classes:
|
|
123
|
+
ov=len(set(chosen)&set(cls))
|
|
124
|
+
maxco=max(maxco,ov)
|
|
125
|
+
print(f" max overlap with any prior variant = {maxco}")
|
|
126
|
+
print(" RESULT_FILES="+",".join(sorted(class_files[c])[0] for c in chosen))
|
|
127
|
+
else:
|
|
128
|
+
print("NO VALID SET FOUND")
|
|
129
|
+
|
|
130
|
+
# also report existing max lesson number
|
|
131
|
+
cur.execute("SELECT variant_id FROM media_posts WHERE variant_id ~ '^lesson-[0-9]+$'")
|
|
132
|
+
nums=[int(r[0].split('-')[1]) for r in cur.fetchall()]
|
|
133
|
+
print("\nDB max lesson num =", max(nums) if nums else None)
|
|
134
|
+
cur.close(); conn.close()
|
package/skill/engage-linkedin.sh
CHANGED
|
@@ -278,8 +278,46 @@ RESET_COUNT=$(li_reset_processing 2)
|
|
|
278
278
|
|
|
279
279
|
PENDING_COUNT=$(li_reply_count pending)
|
|
280
280
|
|
|
281
|
+
# ---- PACING PREFLIGHT (the enforceable half of the pacing gate) -----------
|
|
282
|
+
# The per-reply Step 3b gate inside the Phase B prompt is the fine-grained
|
|
283
|
+
# control, but it depends on the model obeying an instruction. This preflight
|
|
284
|
+
# is the hard stop: when a 24h/72h ceiling is already blown there is nothing
|
|
285
|
+
# Phase B could legally post, so we skip the phase before paying for browser
|
|
286
|
+
# bootstrap, the pending pull and a Claude run.
|
|
287
|
+
#
|
|
288
|
+
# rc=78 ("stop") zeroes PENDING_COUNT rather than exiting, deliberately: an
|
|
289
|
+
# early exit here would skip the run summary and the dashboard metric line at
|
|
290
|
+
# the bottom of this script, and a pipeline that goes quiet WITHOUT reporting
|
|
291
|
+
# is precisely how the 2026-07-20 outage stayed invisible for nine days.
|
|
292
|
+
# rc=75 ("wait") still enters the phase; the per-reply gate sleeps the short
|
|
293
|
+
# remainder itself.
|
|
294
|
+
_LI_PACE_RC=0
|
|
295
|
+
_LI_PACE_JSON="$("$PY_BIN" "$REPO_DIR/scripts/linkedin_pacing.py" check --json 2>&1)" || _LI_PACE_RC=$?
|
|
296
|
+
_LI_PACE_WAIT=$(printf '%s' "$_LI_PACE_JSON" | "$PY_BIN" -c \
|
|
297
|
+
'import json,sys
|
|
298
|
+
try: print(int(json.load(sys.stdin).get("wait_seconds", 0)))
|
|
299
|
+
except Exception: print(0)' 2>/dev/null || echo 0)
|
|
300
|
+
log "PACING: $_LI_PACE_JSON"
|
|
301
|
+
|
|
302
|
+
# Skip Phase B when a ceiling is blown (rc=78) OR when the next slot is far off
|
|
303
|
+
# (rc=75 with a long wait). The long-wait case is mostly the overnight
|
|
304
|
+
# active-hours window: without this we would spin up a full Claude session that
|
|
305
|
+
# is forbidden from posting anything for the next several hours. Short waits
|
|
306
|
+
# (< 15 min) still enter the phase, since drafting takes time anyway and the
|
|
307
|
+
# per-reply Step 3b gate can sleep the small remainder.
|
|
308
|
+
if [ "$PENDING_COUNT" -ne 0 ] && { [ "$_LI_PACE_RC" -eq 78 ] || \
|
|
309
|
+
{ [ "$_LI_PACE_RC" -eq 75 ] && [ "${_LI_PACE_WAIT:-0}" -gt 900 ]; }; }; then
|
|
310
|
+
log "PACING: not postable now (rc=$_LI_PACE_RC, next slot in ${_LI_PACE_WAIT}s); skipping Phase B ($PENDING_COUNT rows stay pending)"
|
|
311
|
+
PENDING_COUNT=0
|
|
312
|
+
LI_PACED_OUT=1
|
|
313
|
+
fi
|
|
314
|
+
|
|
281
315
|
if [ "$PENDING_COUNT" -eq 0 ]; then
|
|
282
|
-
|
|
316
|
+
if [ "${LI_PACED_OUT:-0}" -eq 1 ]; then
|
|
317
|
+
log "Phase B: held back by the pacing gate, not by an empty queue."
|
|
318
|
+
else
|
|
319
|
+
log "Phase B: No pending LinkedIn replies. Done!"
|
|
320
|
+
fi
|
|
283
321
|
else
|
|
284
322
|
log "Phase B: $PENDING_COUNT pending LinkedIn replies to process"
|
|
285
323
|
|
|
@@ -467,6 +505,24 @@ MANDATORY reply flow for every item:
|
|
|
467
505
|
mode ($PICKED_MODE=invent) craft a NEW snake_case style name not in the
|
|
468
506
|
curated block above and pass it as the [engagement_style] arg in Step 5.
|
|
469
507
|
Professional but casual. NEVER em dashes. Match parent post language.
|
|
508
|
+
Step 3b: PACING GATE - MANDATORY, run this IMMEDIATELY BEFORE every single
|
|
509
|
+
post in Step 4. Never post two replies without a fresh check in
|
|
510
|
+
between; never batch several posts behind one check.
|
|
511
|
+
python3 $REPO_DIR/scripts/linkedin_pacing.py check --json
|
|
512
|
+
Read the JSON "action" field and obey it exactly:
|
|
513
|
+
"allow" (exit 0) -> proceed to Step 4 for THIS reply only.
|
|
514
|
+
"wait" (exit 75) -> sleep the returned "wait_seconds", then
|
|
515
|
+
re-run the check. If it still says wait after
|
|
516
|
+
3 attempts, LEAVE the row pending (do NOT mark
|
|
517
|
+
skipped) and move to the next reply; the next
|
|
518
|
+
cycle will pick it up.
|
|
519
|
+
"stop" (exit 78) -> a 24h/72h ceiling is blown. STOP POSTING
|
|
520
|
+
ENTIRELY for this run. Leave all remaining
|
|
521
|
+
rows pending and finish the run cleanly.
|
|
522
|
+
Do NOT try to out-think, average, or "catch up on" this gate; it is
|
|
523
|
+
the control that keeps our comment cadence from looking like a
|
|
524
|
+
metronome. On 2026-07-20 an unpaced run put 23 comments out in 22
|
|
525
|
+
minutes at 60s +/- 29s and the session was killed that evening.
|
|
470
526
|
Step 4: post reply (OAuth API first, browser fallback)
|
|
471
527
|
Step 5: python3 reply_db.py replied ID "text" [url] [engagement_style] [is_recommendation] <- mark AFTER success. engagement_style is the style name you applied (in USE mode the assigned '${PICKED_STYLE}'). is_recommendation="1" only when you mentioned a project (Tier 2/3).
|
|
472
528
|
If Step 5 fails, the item stays 'processing' and will be reset to 'pending' on the next run.
|
package/skill/run-linkedin.sh
CHANGED
|
@@ -106,6 +106,30 @@ export SA_CYCLE_ID="$BATCH_ID"
|
|
|
106
106
|
|
|
107
107
|
echo "=== LinkedIn Post Run: $(date) (batch=$BATCH_ID) ===" | tee "$LOG_FILE"
|
|
108
108
|
|
|
109
|
+
# ---- PACING GATE (2026-07-30) --------------------------------------------
|
|
110
|
+
# This pipeline is the LARGER of the two LinkedIn write paths: it logged 67-74
|
|
111
|
+
# actions/day via log_post.py in the week before the 2026-07-20 session kill,
|
|
112
|
+
# against ~23/day from engage-linkedin.sh. Gating only that other script would
|
|
113
|
+
# have left ~75% of our LinkedIn footprint unpaced.
|
|
114
|
+
#
|
|
115
|
+
# Placed BEFORE Phase A on purpose. Phase B posts exactly ONE comment per fire,
|
|
116
|
+
# so a single per-run check is sufficient, and bailing here also skips Phase A
|
|
117
|
+
# discovery (~$10-15 of Claude spend) rather than paying for candidates we are
|
|
118
|
+
# not allowed to act on.
|
|
119
|
+
#
|
|
120
|
+
# Any non-allow verdict skips the fire outright: this job is on a 15-minute
|
|
121
|
+
# launchd cadence, so the next fire re-checks shortly. We deliberately do not
|
|
122
|
+
# sleep inside the run and hold the browser lock while waiting.
|
|
123
|
+
if [ "$LINKEDIN_BACKEND" = "browser" ]; then
|
|
124
|
+
_LI_PACE_RC=0
|
|
125
|
+
_LI_PACE_OUT="$(python3 "$REPO_DIR/scripts/linkedin_pacing.py" check 2>&1)" || _LI_PACE_RC=$?
|
|
126
|
+
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] PACING: $_LI_PACE_OUT" | tee -a "$LOG_FILE"
|
|
127
|
+
if [ "$_LI_PACE_RC" -ne 0 ]; then
|
|
128
|
+
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] PACING: skipping this fire (rc=$_LI_PACE_RC); next fire re-checks in ~15min" | tee -a "$LOG_FILE"
|
|
129
|
+
exit 0
|
|
130
|
+
fi
|
|
131
|
+
fi
|
|
132
|
+
|
|
109
133
|
# 2026-05-01: lock policy was changed from "hold for the entire run" to
|
|
110
134
|
# "hold only while a Claude phase is actively driving the browser". The old
|
|
111
135
|
# policy meant a single 25-45min cycle held linkedin-browser exclusively for
|