social-autoposter 1.6.34 → 1.6.35
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 +40 -2
- package/mcp-servers/browser-harness/server.py +48 -3
- package/package.json +3 -1
- package/scripts/restore_twitter_session.py +66 -25
- package/scripts/setup_twitter_auth.py +162 -45
- package/scripts/twitter_cookie_mirror.py +121 -0
- package/skill/lib/twitter-backend.sh +9 -0
- package/scripts/install_lane_monitor.py +0 -111
- package/scripts/li_discover_insert.py +0 -183
package/bin/cli.js
CHANGED
|
@@ -1116,11 +1116,49 @@ function doctor() {
|
|
|
1116
1116
|
`print(c.execute("SELECT COUNT(*) FROM cookies WHERE host_key LIKE '%x.com' OR host_key LIKE '%twitter.com'").fetchone()[0])`,
|
|
1117
1117
|
], { encoding: 'utf8', timeout: 10000 });
|
|
1118
1118
|
const n = parseInt((r.stdout || '0').trim(), 10);
|
|
1119
|
-
if (n > 0) return { ok: true, detail: `${n} rows persisted (
|
|
1119
|
+
if (n > 0) return { ok: true, detail: `${n} rows persisted (Chrome's encrypted store)` };
|
|
1120
1120
|
return {
|
|
1121
1121
|
ok: false,
|
|
1122
1122
|
detail: '0 x.com rows in SQLite',
|
|
1123
|
-
fix: 'run setup_twitter_auth.py connect to import
|
|
1123
|
+
fix: 'run setup_twitter_auth.py connect to import (durability is backed by the cookie mirror below)',
|
|
1124
|
+
};
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
// Gap B durability layer (1.6.35+): the keychain-independent local cookie
|
|
1128
|
+
// mirror is what survives a re-locked keychain wiping Chrome's encrypted
|
|
1129
|
+
// Cookies DB on relaunch. Read it directly (plaintext 0600 JSON).
|
|
1130
|
+
const mirrorPath = path.join(HOME, '.claude', 'browser-profiles', 'browser-harness.x-cookies.json');
|
|
1131
|
+
const mirrorCount = () => {
|
|
1132
|
+
try {
|
|
1133
|
+
const data = JSON.parse(fs.readFileSync(mirrorPath, 'utf8'));
|
|
1134
|
+
return Array.isArray(data.cookies) ? data.cookies.length : 0;
|
|
1135
|
+
} catch { return -1; }
|
|
1136
|
+
};
|
|
1137
|
+
|
|
1138
|
+
add('X cookie mirror (durable across keychain re-lock)', () => {
|
|
1139
|
+
const n = mirrorCount();
|
|
1140
|
+
if (n > 0) return { ok: true, detail: `${n} cookies mirrored — cycle preflight auto-restores after a wipe` };
|
|
1141
|
+
if (n === 0) return { ok: false, detail: 'mirror file present but empty', fix: 'run setup_twitter_auth.py connect to (re)populate the mirror' };
|
|
1142
|
+
return { ok: false, detail: `no mirror at ${mirrorPath}`, fix: 'run setup_twitter_auth.py connect (1.6.35+) to create the durable cookie mirror' };
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
add('macOS Keychain: login keychain auto-lock', () => {
|
|
1146
|
+
if (process.platform !== 'darwin') return { ok: true, detail: 'skipped (non-macOS)' };
|
|
1147
|
+
const kc = path.join(HOME, 'Library', 'Keychains', 'login.keychain-db');
|
|
1148
|
+
const r = spawnSync('security', ['show-keychain-info', kc], { encoding: 'utf8', timeout: 10000 });
|
|
1149
|
+
const out = `${r.stdout || ''}${r.stderr || ''}`;
|
|
1150
|
+
const m = out.match(/timeout=(\d+)s/);
|
|
1151
|
+
if (!m) return { ok: true, detail: 'no auto-lock timeout (encrypted cookie store stays decryptable)' };
|
|
1152
|
+
const secs = parseInt(m[1], 10);
|
|
1153
|
+
// Only a real problem if the keychain re-locks AND the mirror isn't there to
|
|
1154
|
+
// cover the resulting Cookies-DB wipe. With a populated mirror this is benign.
|
|
1155
|
+
if (mirrorCount() > 0) {
|
|
1156
|
+
return { ok: true, detail: `auto-locks after ${secs}s, but the cookie mirror covers the relaunch-wipe case` };
|
|
1157
|
+
}
|
|
1158
|
+
return {
|
|
1159
|
+
ok: false,
|
|
1160
|
+
detail: `auto-locks after ${secs}s — Chrome's encrypted cookie store can wipe on relaunch with no mirror to restore from`,
|
|
1161
|
+
fix: `run connect_x to create the cookie mirror, or disable auto-lock: security set-keychain-settings "${kc}"`,
|
|
1124
1162
|
};
|
|
1125
1163
|
});
|
|
1126
1164
|
|
|
@@ -128,6 +128,39 @@ def _log(msg: str) -> None:
|
|
|
128
128
|
|
|
129
129
|
# --- Chrome lifecycle ---
|
|
130
130
|
|
|
131
|
+
# Hardcoded fallbacks used only the very first time a profile launches (before
|
|
132
|
+
# Chrome has written any window_placement to its Preferences).
|
|
133
|
+
DEFAULT_WINDOW_POS = "3042,-1032"
|
|
134
|
+
DEFAULT_WINDOW_SIZE = "1024,1013"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _persisted_window_geometry() -> tuple[str | None, str | None]:
|
|
138
|
+
"""Read the window position+size Chrome last persisted for THIS profile.
|
|
139
|
+
|
|
140
|
+
Chrome writes the live window bounds to <profile>/Default/Preferences ->
|
|
141
|
+
browser.window_placement (left/top/right/bottom, in screen coords) whenever
|
|
142
|
+
the user moves/resizes the window. By reading that back and feeding it into
|
|
143
|
+
the launch flags, a user's manual placement survives SIGKILL+relaunch
|
|
144
|
+
instead of snapping back to the hardcoded default. Returns ("X,Y", "W,H")
|
|
145
|
+
or (None, None) when nothing usable is persisted yet.
|
|
146
|
+
"""
|
|
147
|
+
pref = PROFILE_DIR / "Default" / "Preferences"
|
|
148
|
+
try:
|
|
149
|
+
wp = json.loads(pref.read_text()).get("browser", {}).get("window_placement")
|
|
150
|
+
except (FileNotFoundError, ValueError, OSError):
|
|
151
|
+
return (None, None)
|
|
152
|
+
if not isinstance(wp, dict) or wp.get("maximized"):
|
|
153
|
+
return (None, None)
|
|
154
|
+
try:
|
|
155
|
+
left, top = int(wp["left"]), int(wp["top"])
|
|
156
|
+
width, height = int(wp["right"]) - left, int(wp["bottom"]) - top
|
|
157
|
+
except (KeyError, TypeError, ValueError):
|
|
158
|
+
return (None, None)
|
|
159
|
+
if width <= 0 or height <= 0:
|
|
160
|
+
return (f"{left},{top}", None)
|
|
161
|
+
return (f"{left},{top}", f"{width},{height}")
|
|
162
|
+
|
|
163
|
+
|
|
131
164
|
def _port_open(port: int) -> bool:
|
|
132
165
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
133
166
|
s.settimeout(0.5)
|
|
@@ -187,12 +220,24 @@ def _build_chrome_cmd() -> list[str]:
|
|
|
187
220
|
cmd.append("--no-sandbox")
|
|
188
221
|
cmd.append("--disable-dev-shm-usage")
|
|
189
222
|
|
|
190
|
-
#
|
|
223
|
+
# Persistent window placement on macOS multi-monitor setups.
|
|
191
224
|
# Skip on headless / Linux where positioning is meaningless and the
|
|
192
225
|
# off-screen values would just hide the window on a single-monitor setup.
|
|
226
|
+
# Position priority (2026-06-02):
|
|
227
|
+
# 1. BH_WINDOW_POS / BH_WINDOW_SIZE env (explicit hard override)
|
|
228
|
+
# 2. whatever Chrome last persisted for this profile (the user's own
|
|
229
|
+
# manually-dragged position) -> so user placement survives relaunch
|
|
230
|
+
# 3. hardcoded default (first-ever launch only)
|
|
231
|
+
# We still pass an explicit --window-position flag (rather than letting
|
|
232
|
+
# Chrome restore on its own), so SIGKILL+relaunch can't cascade/drift the
|
|
233
|
+
# window: we control the exact value, but that value now tracks the user's
|
|
234
|
+
# last placement instead of a fixed constant.
|
|
193
235
|
if not HEADLESS and not _IS_LINUX:
|
|
194
|
-
|
|
195
|
-
|
|
236
|
+
saved_pos, saved_size = _persisted_window_geometry()
|
|
237
|
+
win_pos = os.environ.get("BH_WINDOW_POS") or saved_pos or DEFAULT_WINDOW_POS
|
|
238
|
+
win_size = os.environ.get("BH_WINDOW_SIZE") or saved_size or DEFAULT_WINDOW_SIZE
|
|
239
|
+
cmd.append(f"--window-position={win_pos}")
|
|
240
|
+
cmd.append(f"--window-size={win_size}")
|
|
196
241
|
|
|
197
242
|
# Open a real tab so CDP has something to attach to immediately.
|
|
198
243
|
cmd.append("about:blank")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "social-autoposter",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.35",
|
|
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"
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
"!scripts/backfill_real_clicks.py",
|
|
20
20
|
"!scripts/historical_engagement.py",
|
|
21
21
|
"!scripts/style_length_report.py",
|
|
22
|
+
"!scripts/install_lane_monitor.py",
|
|
23
|
+
"!scripts/li_discover_insert.py",
|
|
22
24
|
"!scripts/_dm_record_sent.sh",
|
|
23
25
|
"!scripts/send_batch_dms.sh",
|
|
24
26
|
"!scripts/mint_podlog_subpage_*.py",
|
|
@@ -31,6 +31,14 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
31
31
|
from http_api import api_get # noqa: E402
|
|
32
32
|
from twitter_account import resolve_handle # noqa: E402
|
|
33
33
|
|
|
34
|
+
# Local 0600 cookie mirror — the keychain-independent restore source for
|
|
35
|
+
# persistent machines (Gap B). Tried before the server store. Stdlib-only;
|
|
36
|
+
# guarded so a path quirk never breaks the cycle preflight.
|
|
37
|
+
try:
|
|
38
|
+
import twitter_cookie_mirror # noqa: E402
|
|
39
|
+
except Exception:
|
|
40
|
+
twitter_cookie_mirror = None
|
|
41
|
+
|
|
34
42
|
try:
|
|
35
43
|
from websocket import create_connection
|
|
36
44
|
except ImportError:
|
|
@@ -48,7 +56,12 @@ def _attach():
|
|
|
48
56
|
new = json.load(urllib.request.urlopen(
|
|
49
57
|
urllib.request.Request(f"{CDP}/json/new?about:blank", method="PUT"), timeout=10))
|
|
50
58
|
page = new
|
|
51
|
-
|
|
59
|
+
# suppress_origin: Chrome 111+ enforces CDP WebSocket origin checking and
|
|
60
|
+
# rejects the handshake with 403 unless Chrome was launched with
|
|
61
|
+
# --remote-allow-origins. The harness Chrome (twitter-backend.sh) is launched
|
|
62
|
+
# without that flag, so we must suppress the Origin header (localhost CDP is
|
|
63
|
+
# already privileged), matching setup_twitter_auth.py / copy_browser_cookies.py.
|
|
64
|
+
ws = create_connection(page["webSocketDebuggerUrl"], timeout=20, suppress_origin=True)
|
|
52
65
|
state = {"id": 0}
|
|
53
66
|
|
|
54
67
|
def send(method, params=None):
|
|
@@ -96,12 +109,50 @@ def _logged_in(send):
|
|
|
96
109
|
return _has_auth_cookie(send)
|
|
97
110
|
|
|
98
111
|
|
|
99
|
-
def
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
112
|
+
def _inject(send, cookies) -> int:
|
|
113
|
+
"""Inject CDP-shaped cookies via Network.setCookie. Returns accepted count."""
|
|
114
|
+
send("Network.enable")
|
|
115
|
+
ok_count = 0
|
|
116
|
+
for c in cookies:
|
|
117
|
+
params = {k: c[k] for k in (
|
|
118
|
+
"name", "value", "domain", "path", "secure", "httpOnly",
|
|
119
|
+
"sameSite", "expires") if k in c and c[k] is not None}
|
|
120
|
+
r = send("Network.setCookie", params)
|
|
121
|
+
if r.get("result", {}).get("success", True):
|
|
122
|
+
ok_count += 1
|
|
123
|
+
return ok_count
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _stored_cookies():
|
|
127
|
+
"""Return (cookies, source). Tries the LOCAL mirror first — it's the only
|
|
128
|
+
durable source on a persistent machine, where the server store is skipped
|
|
129
|
+
for lack of a social_accounts row — then falls back to the server store
|
|
130
|
+
(the durable source on hourly-reseeded AppMaker VMs)."""
|
|
131
|
+
if twitter_cookie_mirror is not None:
|
|
132
|
+
try:
|
|
133
|
+
mirrored = twitter_cookie_mirror.load_cookies()
|
|
134
|
+
except Exception:
|
|
135
|
+
mirrored = []
|
|
136
|
+
if mirrored:
|
|
137
|
+
return mirrored, f"local mirror ({twitter_cookie_mirror.MIRROR_PATH.name})"
|
|
104
138
|
|
|
139
|
+
handle = None
|
|
140
|
+
try:
|
|
141
|
+
handle = resolve_handle()
|
|
142
|
+
except Exception:
|
|
143
|
+
handle = None
|
|
144
|
+
if handle:
|
|
145
|
+
try:
|
|
146
|
+
resp = api_get("/api/v1/twitter/session-cookies", query={"handle": handle})
|
|
147
|
+
cookies = ((resp or {}).get("data") or {}).get("cookies") or []
|
|
148
|
+
if cookies:
|
|
149
|
+
return cookies, f"server store (@{handle})"
|
|
150
|
+
except Exception as e:
|
|
151
|
+
print(f"restore_twitter_session: server store fetch failed ({e})", file=sys.stderr)
|
|
152
|
+
return [], None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def main():
|
|
105
156
|
try:
|
|
106
157
|
ws, send = _attach()
|
|
107
158
|
except Exception as e:
|
|
@@ -110,34 +161,24 @@ def main():
|
|
|
110
161
|
|
|
111
162
|
try:
|
|
112
163
|
if _logged_in(send):
|
|
113
|
-
print(
|
|
164
|
+
print("restore_twitter_session: already logged in; no-op")
|
|
114
165
|
return 0
|
|
115
166
|
|
|
116
|
-
|
|
117
|
-
resp = api_get("/api/v1/twitter/session-cookies", query={"handle": handle})
|
|
118
|
-
data = (resp or {}).get("data") or {}
|
|
119
|
-
cookies = data.get("cookies") or []
|
|
167
|
+
cookies, source = _stored_cookies()
|
|
120
168
|
if not cookies:
|
|
121
|
-
print("restore_twitter_session: no stored cookies
|
|
169
|
+
print("restore_twitter_session: no stored cookies (local mirror empty + no "
|
|
170
|
+
"server store); manual connect_x required", file=sys.stderr)
|
|
122
171
|
return 1
|
|
123
172
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
send("Network.enable")
|
|
127
|
-
ok_count = 0
|
|
128
|
-
for c in cookies:
|
|
129
|
-
params = {k: c[k] for k in (
|
|
130
|
-
"name", "value", "domain", "path", "secure", "httpOnly",
|
|
131
|
-
"sameSite", "expires") if k in c and c[k] is not None}
|
|
132
|
-
r = send("Network.setCookie", params)
|
|
133
|
-
if r.get("result", {}).get("success", True):
|
|
134
|
-
ok_count += 1
|
|
173
|
+
print(f"restore_twitter_session: logged out, restoring from {source}...")
|
|
174
|
+
ok_count = _inject(send, cookies)
|
|
135
175
|
print(f"restore_twitter_session: injected {ok_count}/{len(cookies)} cookies")
|
|
136
176
|
|
|
137
177
|
if _logged_in(send):
|
|
138
|
-
print(f"restore_twitter_session: RESTORED
|
|
178
|
+
print(f"restore_twitter_session: RESTORED session from {source}")
|
|
139
179
|
return 0
|
|
140
|
-
print("restore_twitter_session: injection done but still logged out
|
|
180
|
+
print("restore_twitter_session: injection done but still logged out "
|
|
181
|
+
"(cookies may be expired); manual connect_x required", file=sys.stderr)
|
|
141
182
|
return 1
|
|
142
183
|
finally:
|
|
143
184
|
try:
|
|
@@ -74,6 +74,13 @@ except Exception:
|
|
|
74
74
|
api_post = None
|
|
75
75
|
resolve_handle = None
|
|
76
76
|
|
|
77
|
+
# Local 0600 cookie mirror — the keychain-independent durability layer (Gap B).
|
|
78
|
+
# Always importable (stdlib only); guarded so a path quirk never breaks setup.
|
|
79
|
+
try:
|
|
80
|
+
import twitter_cookie_mirror # noqa: E402
|
|
81
|
+
except Exception:
|
|
82
|
+
twitter_cookie_mirror = None
|
|
83
|
+
|
|
77
84
|
# --- Config -----------------------------------------------------------------
|
|
78
85
|
|
|
79
86
|
# Same managed Chrome the twitter-harness pipeline uses (skill/lib/twitter-backend.sh).
|
|
@@ -277,48 +284,75 @@ def _has_session_quick() -> bool:
|
|
|
277
284
|
pass
|
|
278
285
|
|
|
279
286
|
|
|
280
|
-
def
|
|
281
|
-
"""
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
287
|
+
def _collect_x_cookies(send) -> list:
|
|
288
|
+
"""Read the live x.com/twitter.com cookies (CDP shape) from the managed
|
|
289
|
+
Chrome. Returns [] if none. Shared by the mirror + server-store writers."""
|
|
290
|
+
send("Network.enable")
|
|
291
|
+
r = send("Network.getAllCookies")
|
|
292
|
+
cks = r.get("result", {}).get("cookies", []) or []
|
|
293
|
+
wanted = tuple(d.strip() for d in DOMAINS.split(",") if d.strip())
|
|
294
|
+
return [c for c in cks if any(w in (c.get("domain") or "") for w in wanted)]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _persist_session() -> None:
|
|
298
|
+
"""Persist the validated live X session for auto-restore after ANY logout
|
|
299
|
+
(hard kill, crash, keychain re-lock wiping Chrome's Cookies DB, or AppMaker
|
|
300
|
+
VM reseed). One CDP attach feeds two sinks:
|
|
301
|
+
|
|
302
|
+
1. LOCAL 0600 mirror (twitter_cookie_mirror) — ALWAYS written. This is the
|
|
303
|
+
keychain-independent durability layer that fixes Gap B on a persistent
|
|
304
|
+
machine: restore_twitter_session.py re-injects from it on the next cycle
|
|
305
|
+
preflight even after Chrome wiped its own encrypted store.
|
|
306
|
+
2. Server-side session store (POST /api/v1/twitter/session-cookies) —
|
|
307
|
+
best-effort. Enables VM auto-restore where the profile is reseeded
|
|
308
|
+
hourly. No-op on a persistent machine with no social_accounts row.
|
|
309
|
+
|
|
310
|
+
Non-fatal end-to-end: the local session is already valid; this only enables
|
|
311
|
+
future auto-recovery, so nothing here may abort connect_x."""
|
|
296
312
|
try:
|
|
297
313
|
ws, send = _attach()
|
|
298
314
|
except Exception:
|
|
299
315
|
return
|
|
300
316
|
try:
|
|
301
|
-
send
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
wanted = tuple(d.strip() for d in DOMAINS.split(",") if d.strip())
|
|
305
|
-
cookies = [c for c in cks if any(w in (c.get("domain") or "") for w in wanted)]
|
|
306
|
-
if not cookies:
|
|
307
|
-
return
|
|
308
|
-
api_post("/api/v1/twitter/session-cookies", {"handle": handle, "cookies": cookies})
|
|
309
|
-
print(f"setup_twitter_auth: saved {len(cookies)} session cookies for @{handle} "
|
|
310
|
-
"(auto-restore enabled)", file=sys.stderr)
|
|
311
|
-
# api_post raises SystemExit (BaseException, NOT Exception) on a 4xx/5xx —
|
|
312
|
-
# e.g. "no social_accounts row" on a persistent machine that never registered
|
|
313
|
-
# this handle. The save is best-effort and must never abort connect_x, so
|
|
314
|
-
# catch SystemExit too.
|
|
315
|
-
except (Exception, SystemExit) as e:
|
|
316
|
-
print(f"setup_twitter_auth: session-store save skipped ({e})", file=sys.stderr)
|
|
317
|
+
cookies = _collect_x_cookies(send)
|
|
318
|
+
except Exception:
|
|
319
|
+
cookies = []
|
|
317
320
|
finally:
|
|
318
321
|
try:
|
|
319
322
|
ws.close()
|
|
320
323
|
except Exception:
|
|
321
324
|
pass
|
|
325
|
+
if not cookies:
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
handle = None
|
|
329
|
+
if resolve_handle is not None:
|
|
330
|
+
try:
|
|
331
|
+
handle = resolve_handle()
|
|
332
|
+
except Exception:
|
|
333
|
+
handle = None
|
|
334
|
+
|
|
335
|
+
# 1. Local mirror — always, keychain-independent.
|
|
336
|
+
if twitter_cookie_mirror is not None:
|
|
337
|
+
try:
|
|
338
|
+
n = twitter_cookie_mirror.save_cookies(cookies, handle=handle)
|
|
339
|
+
print(f"setup_twitter_auth: mirrored {n} x.com cookies to "
|
|
340
|
+
f"{twitter_cookie_mirror.MIRROR_PATH} (survives keychain re-lock "
|
|
341
|
+
"/ Cookies-DB wipe on relaunch)", file=sys.stderr)
|
|
342
|
+
except Exception as e:
|
|
343
|
+
print(f"setup_twitter_auth: local mirror save skipped ({e})", file=sys.stderr)
|
|
344
|
+
|
|
345
|
+
# 2. Server store — best-effort, only when a handle resolves.
|
|
346
|
+
if api_post is not None and handle:
|
|
347
|
+
try:
|
|
348
|
+
api_post("/api/v1/twitter/session-cookies", {"handle": handle, "cookies": cookies})
|
|
349
|
+
print(f"setup_twitter_auth: saved {len(cookies)} session cookies for @{handle} "
|
|
350
|
+
"(server auto-restore enabled)", file=sys.stderr)
|
|
351
|
+
# api_post raises SystemExit (BaseException, NOT Exception) on a 4xx/5xx —
|
|
352
|
+
# e.g. "no social_accounts row" on a persistent machine that never
|
|
353
|
+
# registered this handle. Best-effort: must never abort connect_x.
|
|
354
|
+
except (Exception, SystemExit) as e:
|
|
355
|
+
print(f"setup_twitter_auth: session-store save skipped ({e})", file=sys.stderr)
|
|
322
356
|
|
|
323
357
|
|
|
324
358
|
def _show_window_and_open_login() -> bool:
|
|
@@ -475,19 +509,76 @@ def _classify_import_error(detail: str | None) -> str:
|
|
|
475
509
|
return "unknown"
|
|
476
510
|
|
|
477
511
|
|
|
478
|
-
def
|
|
479
|
-
"""
|
|
512
|
+
def _cookies_db_path() -> Path | None:
|
|
513
|
+
"""Resolve the harness profile's on-disk Cookies SQLite. Newer Chrome nests
|
|
514
|
+
it under Default/Network/; older builds keep it at Default/. Returns whichever
|
|
515
|
+
exists (most-recently-modified wins if both linger), or None."""
|
|
516
|
+
candidates = [
|
|
517
|
+
PROFILE_DIR / "Default" / "Network" / "Cookies",
|
|
518
|
+
PROFILE_DIR / "Default" / "Cookies",
|
|
519
|
+
]
|
|
520
|
+
existing = [p for p in candidates if p.exists()]
|
|
521
|
+
if not existing:
|
|
522
|
+
return None
|
|
523
|
+
return max(existing, key=lambda p: p.stat().st_mtime)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _count_x_cookies_on_disk() -> int:
|
|
527
|
+
"""Count x.com/twitter.com rows committed to the on-disk Cookies SQLite.
|
|
528
|
+
|
|
529
|
+
Reads a temp COPY of the DB (+ -wal/-shm) so an in-flight write by the live
|
|
530
|
+
Chrome can't lock us out, and opens it read-write on the copy so WAL-resident
|
|
531
|
+
rows are visible (a read-only open would miss not-yet-checkpointed writes —
|
|
532
|
+
exactly the rows we are polling for). Returns the count, or -1 if the DB is
|
|
533
|
+
missing/unreadable."""
|
|
534
|
+
db = _cookies_db_path()
|
|
535
|
+
if not db:
|
|
536
|
+
return -1
|
|
537
|
+
import shutil
|
|
538
|
+
import sqlite3
|
|
539
|
+
import tempfile
|
|
540
|
+
tmpdir = None
|
|
541
|
+
try:
|
|
542
|
+
tmpdir = Path(tempfile.mkdtemp(prefix="saps_flushchk_"))
|
|
543
|
+
dst = tmpdir / "Cookies"
|
|
544
|
+
shutil.copy2(db, dst)
|
|
545
|
+
for suffix in ("-wal", "-shm"):
|
|
546
|
+
w = db.parent / (db.name + suffix)
|
|
547
|
+
if w.exists():
|
|
548
|
+
shutil.copy2(w, tmpdir / ("Cookies" + suffix))
|
|
549
|
+
conn = sqlite3.connect(str(dst))
|
|
550
|
+
try:
|
|
551
|
+
n = conn.execute(
|
|
552
|
+
"SELECT COUNT(*) FROM cookies "
|
|
553
|
+
"WHERE host_key LIKE '%x.com' OR host_key LIKE '%twitter.com'"
|
|
554
|
+
).fetchone()[0]
|
|
555
|
+
finally:
|
|
556
|
+
conn.close()
|
|
557
|
+
return int(n)
|
|
558
|
+
except Exception:
|
|
559
|
+
return -1
|
|
560
|
+
finally:
|
|
561
|
+
if tmpdir is not None:
|
|
562
|
+
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
480
563
|
|
|
481
|
-
Verified empirically on Chrome 148/macOS 26: Browser.close synchronously
|
|
482
|
-
commits the in-memory CookieMonster to the on-disk SQLite, but does NOT
|
|
483
|
-
actually terminate the process. We rely on the flush side-effect, so a
|
|
484
|
-
SIGKILL immediately after import no longer wipes the imported cookies.
|
|
485
564
|
|
|
486
|
-
|
|
487
|
-
|
|
565
|
+
def _force_cookie_flush() -> tuple[bool, str]:
|
|
566
|
+
"""Flush Chrome's in-memory cookie store to disk via CDP Browser.close, then
|
|
567
|
+
VERIFY the x.com cookies actually landed in the on-disk SQLite before
|
|
568
|
+
returning (Gap A, 2026-06-02).
|
|
569
|
+
|
|
570
|
+
The bug this fixes: Browser.close acks immediately, but Chrome commits the
|
|
571
|
+
CookieMonster -> SQLite write ASYNCHRONOUSLY (~0.5-5s under load). The old
|
|
572
|
+
code treated the RPC ack as proof of persistence and reported
|
|
573
|
+
flushed_to_disk=true while the disk was still empty, so a doctor run or a
|
|
574
|
+
SIGKILL in that window saw zero cookies. We now poll the on-disk row count
|
|
575
|
+
until the flush is observably durable (or a timeout proves it isn't).
|
|
576
|
+
|
|
577
|
+
Returns (ok, detail). ok=True only when x.com rows are confirmed on disk."""
|
|
488
578
|
bh = Path.home() / ".local" / "bin" / "browser-harness"
|
|
489
579
|
if not bh.exists():
|
|
490
580
|
return False, f"browser-harness CLI missing at {bh}"
|
|
581
|
+
before = _count_x_cookies_on_disk()
|
|
491
582
|
env = os.environ.copy()
|
|
492
583
|
env["BU_CDP_URL"] = CDP
|
|
493
584
|
env.setdefault("BU_NAME", "twitter-harness")
|
|
@@ -502,7 +593,23 @@ def _force_cookie_flush() -> tuple[bool, str]:
|
|
|
502
593
|
return False, f"browser-harness invocation failed: {e}"
|
|
503
594
|
if r.returncode != 0:
|
|
504
595
|
return False, (r.stderr or r.stdout).strip()[:300]
|
|
505
|
-
|
|
596
|
+
|
|
597
|
+
# Poll the disk for the async commit to land. Accept as soon as we observe
|
|
598
|
+
# x.com rows on disk (and, if we had a baseline, that it didn't regress).
|
|
599
|
+
deadline = time.time() + 8.0
|
|
600
|
+
last = before
|
|
601
|
+
while time.time() < deadline:
|
|
602
|
+
n = _count_x_cookies_on_disk()
|
|
603
|
+
if n > 0 and (before <= 0 or n >= before):
|
|
604
|
+
return True, f"verified {n} x.com cookies committed to on-disk SQLite"
|
|
605
|
+
last = n
|
|
606
|
+
time.sleep(0.5)
|
|
607
|
+
if last > 0:
|
|
608
|
+
return True, f"verified {last} x.com cookies on disk (slow flush)"
|
|
609
|
+
return False, (
|
|
610
|
+
f"Browser.close issued but on-disk x.com cookie count is {last} after 8s "
|
|
611
|
+
"(flush not confirmed; relying on the local cookie mirror for durability)"
|
|
612
|
+
)
|
|
506
613
|
|
|
507
614
|
|
|
508
615
|
# --- Commands ---------------------------------------------------------------
|
|
@@ -543,7 +650,7 @@ def cmd_connect(args) -> dict:
|
|
|
543
650
|
# 1. Already logged in? Nothing to import.
|
|
544
651
|
try:
|
|
545
652
|
if _is_session_valid():
|
|
546
|
-
|
|
653
|
+
_persist_session()
|
|
547
654
|
return {
|
|
548
655
|
"ok": True,
|
|
549
656
|
"connected": True,
|
|
@@ -609,13 +716,17 @@ def cmd_connect(args) -> dict:
|
|
|
609
716
|
# 3. Re-validate after this source.
|
|
610
717
|
try:
|
|
611
718
|
if _is_session_valid():
|
|
612
|
-
|
|
719
|
+
_persist_session()
|
|
613
720
|
# #2: force a cookie-store flush via CDP Browser.close so the
|
|
614
721
|
# imported session survives any subsequent SIGKILL (e.g. the
|
|
615
722
|
# autoposter cron stopping Chrome with no grace window). Empty
|
|
616
723
|
# result on this build is success — Browser.close triggers the
|
|
617
724
|
# flush synchronously but doesn't actually terminate Chrome.
|
|
618
725
|
flush_ok, flush_detail = _force_cookie_flush()
|
|
726
|
+
mirror_count = (
|
|
727
|
+
twitter_cookie_mirror.load_meta().get("count")
|
|
728
|
+
if twitter_cookie_mirror is not None else None
|
|
729
|
+
)
|
|
619
730
|
return {
|
|
620
731
|
"ok": True,
|
|
621
732
|
"connected": True,
|
|
@@ -624,10 +735,16 @@ def cmd_connect(args) -> dict:
|
|
|
624
735
|
"attempts": attempts,
|
|
625
736
|
"flushed_to_disk": flush_ok,
|
|
626
737
|
"flush_detail": flush_detail,
|
|
738
|
+
"mirrored_cookies": mirror_count,
|
|
627
739
|
"note": f"Imported your X session from {src} into the autoposter browser. "
|
|
628
|
-
+ ("Cookies
|
|
740
|
+
+ ("Cookies verified on disk AND mirrored locally; "
|
|
629
741
|
if flush_ok else
|
|
630
|
-
"
|
|
742
|
+
"Chrome's encrypted store didn't confirm the flush, but ")
|
|
743
|
+
+ (f"{mirror_count} cookies are saved to a keychain-independent "
|
|
744
|
+
"mirror, so the cycle preflight auto-restores the session even if "
|
|
745
|
+
"Chrome re-launches logged out."
|
|
746
|
+
if mirror_count else
|
|
747
|
+
"the session is live in the running browser."),
|
|
631
748
|
"cdp": CDP,
|
|
632
749
|
}
|
|
633
750
|
except Exception:
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""twitter_cookie_mirror.py - local 0600 mirror of the managed X session cookies.
|
|
3
|
+
|
|
4
|
+
Why this exists (Gap B, 2026-06-02)
|
|
5
|
+
-----------------------------------
|
|
6
|
+
On a persistent (non-VM) machine the server-side session store
|
|
7
|
+
(social_accounts.session_cookies) is SKIPPED during connect_x because there is no
|
|
8
|
+
social_accounts row to attach the cookies to. That left Chrome's own encrypted
|
|
9
|
+
Cookies SQLite as the ONLY thing keeping the X session across a Chrome relaunch.
|
|
10
|
+
|
|
11
|
+
That store is not durable on a headless / SSH box: macOS encrypts Chrome cookies
|
|
12
|
+
with the per-app `Chrome Safe Storage` key, which lives in the login keychain.
|
|
13
|
+
When the keychain re-locks (idle ~5 min) between the import and the next Chrome
|
|
14
|
+
launch, the freshly-launched Chrome cannot read the Safe Storage key, cannot
|
|
15
|
+
decrypt the existing blobs, and reinitializes the Cookies DB to an empty schema.
|
|
16
|
+
The imported session silently evaporates between `connect_x` and the first cycle.
|
|
17
|
+
|
|
18
|
+
This module is the keychain-independent durability layer. On a successful import
|
|
19
|
+
connect_x writes the validated x.com/twitter.com cookies (CDP-shaped, straight
|
|
20
|
+
from Network.getAllCookies) here as plaintext JSON, and the cycle preflight
|
|
21
|
+
(restore_twitter_session.py, invoked from skill/lib/twitter-backend.sh) re-injects
|
|
22
|
+
them via CDP whenever the live session comes up logged out. A keychain re-lock or
|
|
23
|
+
a wiped Cookies DB is therefore no longer fatal — the next cycle restores.
|
|
24
|
+
|
|
25
|
+
Security
|
|
26
|
+
--------
|
|
27
|
+
The file grants access to the X account: it is exactly as sensitive as the Chrome
|
|
28
|
+
profile itself, and is written 0600 (owner read/write only). Treat it like a
|
|
29
|
+
token. It is intentionally NOT encrypted — the whole point is to survive a locked
|
|
30
|
+
keychain, so adding a keychain-derived key would reintroduce the dependency this
|
|
31
|
+
file exists to remove. On a multi-user host, restrict the home directory.
|
|
32
|
+
|
|
33
|
+
CLI (debug / doctor):
|
|
34
|
+
python3 twitter_cookie_mirror.py count # prints the mirrored cookie count
|
|
35
|
+
python3 twitter_cookie_mirror.py path # prints the mirror file path
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import sys
|
|
42
|
+
import time
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
# Sibling of the harness profile dir, NOT inside it: a VM profile reseed wipes
|
|
46
|
+
# the profile but a persistent machine keeps this file across Chrome relaunches.
|
|
47
|
+
# (On a VM the server-side store is the durable path; the mirror just stays empty
|
|
48
|
+
# there and restore_twitter_session falls through to the API.)
|
|
49
|
+
MIRROR_PATH = (
|
|
50
|
+
Path.home() / ".claude" / "browser-profiles" / "browser-harness.x-cookies.json"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def save_cookies(cookies, handle: str | None = None) -> int:
|
|
55
|
+
"""Write the given CDP-shaped cookies to the 0600 mirror. Returns count saved.
|
|
56
|
+
|
|
57
|
+
Atomic (temp file + os.replace) so a crash mid-write never leaves a partial
|
|
58
|
+
JSON that the reader would choke on. No-op (returns 0) on an empty list."""
|
|
59
|
+
clean = [c for c in (cookies or []) if isinstance(c, dict) and c.get("name")]
|
|
60
|
+
if not clean:
|
|
61
|
+
return 0
|
|
62
|
+
MIRROR_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
payload = {"handle": handle, "saved_at": int(time.time()), "cookies": clean}
|
|
64
|
+
tmp = MIRROR_PATH.with_name(MIRROR_PATH.name + ".tmp")
|
|
65
|
+
# Create with 0600 from the start so the secret is never briefly world-readable.
|
|
66
|
+
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
67
|
+
with os.fdopen(fd, "w") as f:
|
|
68
|
+
json.dump(payload, f)
|
|
69
|
+
os.replace(tmp, MIRROR_PATH)
|
|
70
|
+
try:
|
|
71
|
+
os.chmod(MIRROR_PATH, 0o600)
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
return len(clean)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _read() -> dict:
|
|
78
|
+
try:
|
|
79
|
+
with open(MIRROR_PATH) as f:
|
|
80
|
+
data = json.load(f)
|
|
81
|
+
except (OSError, ValueError):
|
|
82
|
+
return {}
|
|
83
|
+
return data if isinstance(data, dict) else {}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_cookies() -> list:
|
|
87
|
+
"""Return the mirrored CDP-shaped cookies, or [] if no/invalid mirror."""
|
|
88
|
+
cks = _read().get("cookies")
|
|
89
|
+
return cks if isinstance(cks, list) else []
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_meta() -> dict:
|
|
93
|
+
"""Return {handle, saved_at, count} for the mirror, or {} if absent."""
|
|
94
|
+
data = _read()
|
|
95
|
+
if not data:
|
|
96
|
+
return {}
|
|
97
|
+
return {
|
|
98
|
+
"handle": data.get("handle"),
|
|
99
|
+
"saved_at": data.get("saved_at"),
|
|
100
|
+
"count": len(data.get("cookies") or []),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _cli(argv: list[str] | None = None) -> int:
|
|
105
|
+
argv = argv if argv is not None else sys.argv[1:]
|
|
106
|
+
cmd = argv[0] if argv else "count"
|
|
107
|
+
if cmd == "path":
|
|
108
|
+
print(MIRROR_PATH)
|
|
109
|
+
return 0
|
|
110
|
+
if cmd == "count":
|
|
111
|
+
print(len(load_cookies()))
|
|
112
|
+
return 0
|
|
113
|
+
if cmd == "meta":
|
|
114
|
+
print(json.dumps(load_meta()))
|
|
115
|
+
return 0
|
|
116
|
+
print(f"usage: {Path(sys.argv[0]).name} [count|path|meta]", file=sys.stderr)
|
|
117
|
+
return 2
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
sys.exit(_cli())
|
|
@@ -214,6 +214,15 @@ ensure_twitter_browser_for_backend() {
|
|
|
214
214
|
fi
|
|
215
215
|
echo "[$(date +%H:%M:%S)] Harness Chrome up on port 9555" >&2
|
|
216
216
|
fi
|
|
217
|
+
# Re-inject the stored X session if the harness Chrome is logged out — e.g. a
|
|
218
|
+
# keychain re-lock wiped Chrome's encrypted Cookies SQLite on this launch
|
|
219
|
+
# (Gap B, 2026-06-02). restore_twitter_session.py reads the keychain-
|
|
220
|
+
# independent local cookie mirror (written by connect_x) and injects via CDP.
|
|
221
|
+
# No-op when already logged in; never blocks the cycle on failure. Runs on
|
|
222
|
+
# both the freshly-launched and already-up paths so a mid-life logout heals.
|
|
223
|
+
TWITTER_CDP_URL="http://127.0.0.1:9555" \
|
|
224
|
+
python3 "$HOME/social-autoposter/scripts/restore_twitter_session.py" 2>&1 \
|
|
225
|
+
| sed 's/^/[restore] /' >&2 || true
|
|
217
226
|
# Always close leftover tabs from prior runs. Safe under acquire_lock
|
|
218
227
|
# "twitter-browser" serialization (every caller of this function holds
|
|
219
228
|
# that lock), so we will not race with another active twitter run.
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Watch the install lane canary in real time.
|
|
3
|
-
|
|
4
|
-
Run any time:
|
|
5
|
-
python3 scripts/install_lane_monitor.py
|
|
6
|
-
|
|
7
|
-
Checks:
|
|
8
|
-
1. Heartbeat freshness — alerts if last beat > 30min old.
|
|
9
|
-
2. Install attribution coverage — % of replies created in the last 24h
|
|
10
|
-
for each platform that have install_id stamped (canary github should
|
|
11
|
-
trend to ~100%; the other 3 should stay at 0% until we flip them).
|
|
12
|
-
3. Stuck-in-processing — replies left in 'processing' > 30min are the
|
|
13
|
-
classic failure mode for the new lane (server claimed the row, then
|
|
14
|
-
a downstream step failed silently).
|
|
15
|
-
4. Recent install lane errors — scans launchd-heartbeat logs for FAIL.
|
|
16
|
-
|
|
17
|
-
Exit code 0 if everything green, 1 if any check fails. Safe in cron.
|
|
18
|
-
"""
|
|
19
|
-
import os, sys, subprocess
|
|
20
|
-
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
|
|
21
|
-
from http_api import api_get, load_env
|
|
22
|
-
|
|
23
|
-
load_env()
|
|
24
|
-
_digest = (api_get("/api/v1/install-lane/digest", query={"within_hours": 24}).get("data") or {})
|
|
25
|
-
|
|
26
|
-
OK = "\033[32m✓\033[0m"
|
|
27
|
-
WARN = "\033[33m!\033[0m"
|
|
28
|
-
FAIL = "\033[31m✗\033[0m"
|
|
29
|
-
status = 0
|
|
30
|
-
|
|
31
|
-
print("=" * 64)
|
|
32
|
-
HTTP_LANE_PLATFORMS = {"github", "reddit", "x", "linkedin", "moltbook"}
|
|
33
|
-
print(f"INSTALL LANE CANARY (HTTP: {', '.join(sorted(HTTP_LANE_PLATFORMS))} / others SQL)")
|
|
34
|
-
print("=" * 64)
|
|
35
|
-
|
|
36
|
-
# 1. Heartbeat freshness
|
|
37
|
-
rows = _digest.get("heartbeat") or []
|
|
38
|
-
print("\n[1] HEARTBEAT")
|
|
39
|
-
if not rows:
|
|
40
|
-
print(f" {FAIL} no installations rows yet")
|
|
41
|
-
status = 1
|
|
42
|
-
else:
|
|
43
|
-
head = rows[0]
|
|
44
|
-
age = head["age_sec"]
|
|
45
|
-
age_disp = f"{age}s" if age < 120 else f"{age // 60}m {age % 60}s"
|
|
46
|
-
flag = OK if age < 1800 else (WARN if age < 3600 else FAIL)
|
|
47
|
-
if age >= 1800:
|
|
48
|
-
status = 1
|
|
49
|
-
print(f" {flag} latest beat {age_disp} ago")
|
|
50
|
-
print(f" install_id {head['install_id']}")
|
|
51
|
-
print(f" hostname {head['hostname']}")
|
|
52
|
-
print(f" beats total {head['request_count']}")
|
|
53
|
-
print(f" last_ip {head['last_ip']} ({head['last_city'] or '?'} / {head['last_country'] or '?'})")
|
|
54
|
-
if len(rows) > 1:
|
|
55
|
-
print(f" +{len(rows)-1} other install(s) seen recently")
|
|
56
|
-
|
|
57
|
-
# 2. Per-platform attribution coverage (last 24h created replies)
|
|
58
|
-
rows = _digest.get("platforms") or []
|
|
59
|
-
print(f"\n[2] LAST 24H REPLIES BY PLATFORM (HTTP-lane: {', '.join(sorted(HTTP_LANE_PLATFORMS))})")
|
|
60
|
-
print(f" {'platform':<10} {'total':>5} {'attrib':>7} {'rep':>5} {'skp':>5} {'prc':>5} {'pnd':>5} notes")
|
|
61
|
-
for r in rows:
|
|
62
|
-
plat, total, attrib = r["platform"], r["total"], r["attributed"]
|
|
63
|
-
replied, skipped, proc, pend = r["replied"], r["skipped"], r["processing"], r["pending"]
|
|
64
|
-
pct = (attrib / total * 100) if total else 0
|
|
65
|
-
note = ""
|
|
66
|
-
if plat in HTTP_LANE_PLATFORMS:
|
|
67
|
-
if total == 0:
|
|
68
|
-
note = "(no traffic yet)"
|
|
69
|
-
elif pct < 80:
|
|
70
|
-
note = f" {WARN} only {pct:.0f}% attributed; expected ~100%"
|
|
71
|
-
status = 1
|
|
72
|
-
else:
|
|
73
|
-
note = f" {OK} {pct:.0f}% attributed"
|
|
74
|
-
else:
|
|
75
|
-
if attrib > 0:
|
|
76
|
-
note = f" {WARN} {attrib} unexpected install_id rows on a SQL-lane platform"
|
|
77
|
-
print(f" {plat:<10} {total:>5} {attrib:>7} {replied:>5} {skipped:>5} {proc:>5} {pend:>5}{note}")
|
|
78
|
-
|
|
79
|
-
# 3. Stuck in 'processing' > 30min — the canonical failure mode of a new claim path
|
|
80
|
-
rows = _digest.get("stuck") or []
|
|
81
|
-
print("\n[3] STUCK IN 'processing' > 30min")
|
|
82
|
-
if not rows:
|
|
83
|
-
print(f" {OK} none")
|
|
84
|
-
else:
|
|
85
|
-
print(f" {WARN} {len(rows)} stuck rows (revert with: UPDATE replies SET status='pending' WHERE id IN (...))")
|
|
86
|
-
for r in rows:
|
|
87
|
-
rid, plat, iid, age = r["id"], r["platform"], r["install_id"], r["age_sec"]
|
|
88
|
-
age_disp = f"{age // 60}m" if age < 7200 else f"{age // 3600}h"
|
|
89
|
-
print(f" id={rid:<6} {plat:<8} iid={(iid or '-')[:8]} {age_disp} ago")
|
|
90
|
-
|
|
91
|
-
# 4. Heartbeat log errors in the last 100 lines
|
|
92
|
-
log_path = os.path.expanduser("~/social-autoposter/skill/logs/heartbeat.log")
|
|
93
|
-
print("\n[4] HEARTBEAT LOG (recent FAILs)")
|
|
94
|
-
if os.path.exists(log_path):
|
|
95
|
-
try:
|
|
96
|
-
out = subprocess.check_output(["tail", "-200", log_path], text=True, timeout=5)
|
|
97
|
-
fails = [ln for ln in out.splitlines() if "FAIL" in ln]
|
|
98
|
-
if not fails:
|
|
99
|
-
print(f" {OK} no failures in last 200 lines")
|
|
100
|
-
else:
|
|
101
|
-
print(f" {FAIL} {len(fails)} failures:")
|
|
102
|
-
for ln in fails[-5:]:
|
|
103
|
-
print(f" {ln}")
|
|
104
|
-
status = 1
|
|
105
|
-
except Exception as e:
|
|
106
|
-
print(f" {WARN} couldn't read log: {e}")
|
|
107
|
-
else:
|
|
108
|
-
print(f" {WARN} log not yet created at {log_path}")
|
|
109
|
-
|
|
110
|
-
print()
|
|
111
|
-
sys.exit(status)
|
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
import json
|
|
3
|
-
import os
|
|
4
|
-
import sys
|
|
5
|
-
import re
|
|
6
|
-
|
|
7
|
-
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
8
|
-
from http_api import api_post
|
|
9
|
-
|
|
10
|
-
EXCLUDED_AUTHORS = {"louis030195", "louis3195"}
|
|
11
|
-
OWN_NAME = "Matthew Diakonov"
|
|
12
|
-
OWN_HANDLES = {"m13v", "matthew-diakonov"}
|
|
13
|
-
|
|
14
|
-
def load_existing_comment_ids():
|
|
15
|
-
s = set()
|
|
16
|
-
with open("/tmp/li_existing_comment_ids.txt") as f:
|
|
17
|
-
for line in f:
|
|
18
|
-
line = line.strip()
|
|
19
|
-
if line:
|
|
20
|
-
s.add(line)
|
|
21
|
-
return s
|
|
22
|
-
|
|
23
|
-
def load_engaged_pairs():
|
|
24
|
-
s = set()
|
|
25
|
-
with open("/tmp/li_engaged_pairs.txt") as f:
|
|
26
|
-
for line in f:
|
|
27
|
-
line = line.strip()
|
|
28
|
-
if line:
|
|
29
|
-
s.add(line)
|
|
30
|
-
return s
|
|
31
|
-
|
|
32
|
-
def load_posts():
|
|
33
|
-
"""Build mapping by activity_id and ugc_id from our_url."""
|
|
34
|
-
by_id = {}
|
|
35
|
-
with open("/tmp/li_posts.txt") as f:
|
|
36
|
-
for line in f:
|
|
37
|
-
line = line.strip()
|
|
38
|
-
if not line:
|
|
39
|
-
continue
|
|
40
|
-
pid_str, _, our_url = line.partition("|")
|
|
41
|
-
try:
|
|
42
|
-
pid = int(pid_str)
|
|
43
|
-
except ValueError:
|
|
44
|
-
continue
|
|
45
|
-
ids = set()
|
|
46
|
-
for m in re.findall(r"urn:li:activity:(\d+)", our_url):
|
|
47
|
-
ids.add(("activity", m))
|
|
48
|
-
for m in re.findall(r"urn:li:ugcPost:(\d+)", our_url):
|
|
49
|
-
ids.add(("ugcPost", m))
|
|
50
|
-
for m in re.findall(r"/feed/update/urn:li:(activity|ugcPost):(\d+)", our_url):
|
|
51
|
-
ids.add((m[0], m[1]))
|
|
52
|
-
for m in re.findall(r"/posts/[^/?#]*?(\d{15,})", our_url):
|
|
53
|
-
ids.add(("any", m))
|
|
54
|
-
for m in re.findall(r"(\d{18,20})", our_url):
|
|
55
|
-
ids.add(("any", m))
|
|
56
|
-
for kind, urn_id in ids:
|
|
57
|
-
by_id.setdefault(urn_id, (pid, our_url))
|
|
58
|
-
return by_id
|
|
59
|
-
|
|
60
|
-
def main():
|
|
61
|
-
existing = load_existing_comment_ids()
|
|
62
|
-
engaged = load_engaged_pairs()
|
|
63
|
-
posts_by_id = load_posts()
|
|
64
|
-
|
|
65
|
-
items = []
|
|
66
|
-
for fn in ("/tmp/li_notifications_batch1.json", "/tmp/li_notifications_batch2.json"):
|
|
67
|
-
with open(fn) as f:
|
|
68
|
-
items.extend(json.load(f))
|
|
69
|
-
|
|
70
|
-
counts = {
|
|
71
|
-
"discovered": 0,
|
|
72
|
-
"already_tracked": 0,
|
|
73
|
-
"author_already_engaged": 0,
|
|
74
|
-
"excluded": 0,
|
|
75
|
-
"own_account": 0,
|
|
76
|
-
"no_comment_urn": 0,
|
|
77
|
-
"post_not_found_skipped": 0,
|
|
78
|
-
"post_created": 0,
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
for it in items:
|
|
82
|
-
author = (it.get("author") or "").strip()
|
|
83
|
-
comment_urn = it.get("comment_urn")
|
|
84
|
-
href = it.get("href")
|
|
85
|
-
snippet = it.get("snippet") or ""
|
|
86
|
-
activity_id = it.get("activity_id")
|
|
87
|
-
ugc_id = it.get("ugc_id")
|
|
88
|
-
|
|
89
|
-
if not comment_urn or not (activity_id or ugc_id):
|
|
90
|
-
counts["no_comment_urn"] += 1
|
|
91
|
-
continue
|
|
92
|
-
|
|
93
|
-
if author == OWN_NAME or author.lower() in OWN_HANDLES:
|
|
94
|
-
counts["own_account"] += 1
|
|
95
|
-
continue
|
|
96
|
-
|
|
97
|
-
author_lower = author.lower()
|
|
98
|
-
if any(ex in author_lower for ex in EXCLUDED_AUTHORS):
|
|
99
|
-
counts["excluded"] += 1
|
|
100
|
-
continue
|
|
101
|
-
|
|
102
|
-
if comment_urn in existing:
|
|
103
|
-
counts["already_tracked"] += 1
|
|
104
|
-
continue
|
|
105
|
-
|
|
106
|
-
# Find post by activity_id first, then ugc_id
|
|
107
|
-
post_id = None
|
|
108
|
-
our_url = None
|
|
109
|
-
for candidate in (activity_id, ugc_id):
|
|
110
|
-
if candidate and candidate in posts_by_id:
|
|
111
|
-
post_id, our_url = posts_by_id[candidate]
|
|
112
|
-
break
|
|
113
|
-
|
|
114
|
-
if post_id is None:
|
|
115
|
-
# Need to insert a new post row
|
|
116
|
-
urn_for_url = activity_id or ugc_id
|
|
117
|
-
kind = "activity" if activity_id else "ugcPost"
|
|
118
|
-
our_url = f"https://www.linkedin.com/feed/update/urn:li:{kind}:{urn_for_url}/"
|
|
119
|
-
# thread_author: best signal we have is the notification author
|
|
120
|
-
# (the replier). It isn't the actual OP, but it's not us, so the
|
|
121
|
-
# dashboard "threads vs comments" filter (server.js /api/top)
|
|
122
|
-
# correctly classifies these as comments under someone else's post.
|
|
123
|
-
thread_author = author or "(unknown)"
|
|
124
|
-
resp = api_post(
|
|
125
|
-
"/api/v1/posts",
|
|
126
|
-
{
|
|
127
|
-
"platform": "linkedin",
|
|
128
|
-
"thread_url": our_url,
|
|
129
|
-
"our_url": our_url,
|
|
130
|
-
"our_content": "[discovered via notification, no original content tracked]",
|
|
131
|
-
"project": "general",
|
|
132
|
-
"thread_author": thread_author,
|
|
133
|
-
"our_account": "Matthew Diakonov",
|
|
134
|
-
"engagement_style": "discovered_via_notification",
|
|
135
|
-
"status": "active",
|
|
136
|
-
},
|
|
137
|
-
ok_on_conflict=True,
|
|
138
|
-
)
|
|
139
|
-
if (resp.get("error") or {}).get("code") == "duplicate_thread":
|
|
140
|
-
# Post already exists in DB but wasn't in our /tmp/li_posts.txt
|
|
141
|
-
# local map; reuse the existing row instead of creating a dup.
|
|
142
|
-
post_id = (resp["error"].get("details") or {}).get("existing_post_id")
|
|
143
|
-
else:
|
|
144
|
-
post_id = ((resp.get("data") or {}).get("post") or {}).get("id")
|
|
145
|
-
if post_id is None:
|
|
146
|
-
counts["post_not_found_skipped"] += 1
|
|
147
|
-
continue
|
|
148
|
-
counts["post_created"] += 1
|
|
149
|
-
# Add to in-memory map so subsequent items in same loop reuse it
|
|
150
|
-
for cand in (activity_id, ugc_id):
|
|
151
|
-
if cand:
|
|
152
|
-
posts_by_id[cand] = (post_id, our_url)
|
|
153
|
-
|
|
154
|
-
pair_key = f"{author}|||{our_url}"
|
|
155
|
-
if pair_key in engaged:
|
|
156
|
-
counts["author_already_engaged"] += 1
|
|
157
|
-
continue
|
|
158
|
-
|
|
159
|
-
# Insert reply (find-or-create; 409 duplicate is fine, the gate may
|
|
160
|
-
# also return 200 with reply:null which we treat as discovered-then-gated).
|
|
161
|
-
api_post(
|
|
162
|
-
"/api/v1/replies",
|
|
163
|
-
{
|
|
164
|
-
"platform": "linkedin",
|
|
165
|
-
"post_id": post_id,
|
|
166
|
-
"their_comment_id": comment_urn,
|
|
167
|
-
"their_author": author,
|
|
168
|
-
"their_content": snippet,
|
|
169
|
-
"their_comment_url": href,
|
|
170
|
-
"depth": 1,
|
|
171
|
-
"status": "pending",
|
|
172
|
-
},
|
|
173
|
-
ok_on_conflict=True,
|
|
174
|
-
)
|
|
175
|
-
|
|
176
|
-
existing.add(comment_urn)
|
|
177
|
-
engaged.add(pair_key)
|
|
178
|
-
counts["discovered"] += 1
|
|
179
|
-
|
|
180
|
-
print(json.dumps(counts, indent=2))
|
|
181
|
-
|
|
182
|
-
if __name__ == "__main__":
|
|
183
|
-
main()
|