social-autoposter 1.6.64 → 1.6.65
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/package.json +1 -1
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/capture_thread_media.py +40 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/linkedin_browser.py +153 -43
- package/scripts/linkedin_unipile.py +48 -4
- package/scripts/log_post.py +26 -1
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/stats.py +53 -7
- package/scripts/strike_alert.py +102 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/twitter_access_check.py +278 -0
- package/scripts/twitter_browser.py +168 -37
- package/skill/engage-twitter.sh +2 -1
- package/skill/lib/linkedin-backend.sh +10 -36
- package/skill/lock.sh +37 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-linkedin.sh +22 -2
- package/skill/run-twitter-cycle.sh +10 -7
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
|
-
"display_name": "Social Autoposter
|
|
4
|
+
"display_name": "Social Autoposter",
|
|
5
5
|
"version": "0.0.1",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts. Thin desktop client over the social-autoposter pipeline.",
|
|
7
7
|
"long_description": "A guided assistant that drafts, reviews, and autopilots X/Twitter posts.\nTo get started:\n1. Fully quit and restart Claude (quit and reopen, not just close the window).\n2. Start a new session and type **run social autoposter dashboard now** to set up the local runtime.",
|
package/package.json
CHANGED
package/scripts/_filt.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import sys,json
|
|
2
|
+
d=json.load(sys.stdin)
|
|
3
|
+
print("result_count:",d.get("result_count"),"error:",d.get("error"))
|
|
4
|
+
for r in d.get("results",[])[:5]:
|
|
5
|
+
name=r.get("author_name")
|
|
6
|
+
hl=(r.get("author_headline") or "")[:75]
|
|
7
|
+
t=(r.get("post_text") or "")[:170].replace("\n"," ")
|
|
8
|
+
print(" - %s | %s | age=%sh rx=%s c=%s vs=%s" % (name,hl,r.get("age_hours"),r.get("reactions"),r.get("comments"),r.get("velocity_score")))
|
|
9
|
+
print(" "+t)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import json, re
|
|
2
|
+
|
|
3
|
+
items = json.load(open("/tmp/li_actionable.json"))
|
|
4
|
+
cids = set(l.strip() for l in open("/tmp/li_cids.txt") if l.strip())
|
|
5
|
+
pairs = set(l.strip() for l in open("/tmp/li_pairs.txt") if l.strip())
|
|
6
|
+
posts = []
|
|
7
|
+
for l in open("/tmp/li_posts.txt"):
|
|
8
|
+
l=l.strip()
|
|
9
|
+
if not l: continue
|
|
10
|
+
pid, _, url = l.partition("|")
|
|
11
|
+
posts.append((pid, url))
|
|
12
|
+
|
|
13
|
+
EXCLUDED = {"louis030195","louis3195"}
|
|
14
|
+
OWN = {"matthew diakonov","m13v"}
|
|
15
|
+
|
|
16
|
+
def parse_urn(urn):
|
|
17
|
+
# urn:li:comment:(NS:PARENT,COMMENT)
|
|
18
|
+
m = re.match(r"urn:li:comment:\((\w+):(\d+),(\d+)\)", urn or "")
|
|
19
|
+
if not m: return (None,None,None)
|
|
20
|
+
return m.group(1), m.group(2), m.group(3)
|
|
21
|
+
|
|
22
|
+
def find_post_id(parent_id):
|
|
23
|
+
for pid, url in posts:
|
|
24
|
+
if parent_id and parent_id in url:
|
|
25
|
+
return pid
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
def author_engaged(author, parent_id):
|
|
29
|
+
# any engaged pair with same author AND url containing parent_id
|
|
30
|
+
al = author.strip().lower()
|
|
31
|
+
for p in pairs:
|
|
32
|
+
a, _, url = p.partition("|||")
|
|
33
|
+
if a.strip().lower()==al and parent_id and parent_id in url:
|
|
34
|
+
return True
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
seen_batch = set()
|
|
38
|
+
plan = []
|
|
39
|
+
counts = dict(new=0, already=0, engaged=0, excluded=0, own=0, nourn=0, dup_batch=0)
|
|
40
|
+
|
|
41
|
+
for it in items:
|
|
42
|
+
urn = it.get("comment_urn")
|
|
43
|
+
author = (it.get("author") or "").strip()
|
|
44
|
+
ns, parent_id, comment_id = parse_urn(urn)
|
|
45
|
+
rec = dict(it, ns=ns, parent_id=parent_id, comment_id=comment_id, decision=None, post_id=None)
|
|
46
|
+
|
|
47
|
+
if not urn or not parent_id:
|
|
48
|
+
rec["decision"]="skip:no_comment_urn"; counts["nourn"]+=1; plan.append(rec); continue
|
|
49
|
+
if urn in seen_batch:
|
|
50
|
+
rec["decision"]="skip:dup_in_batch"; counts["dup_batch"]+=1; plan.append(rec); continue
|
|
51
|
+
seen_batch.add(urn)
|
|
52
|
+
if urn in cids:
|
|
53
|
+
rec["decision"]="skip:already_tracked"; counts["already"]+=1; plan.append(rec); continue
|
|
54
|
+
al = author.lower()
|
|
55
|
+
if al in OWN or any(al==e for e in EXCLUDED):
|
|
56
|
+
rec["decision"]="skip:own_or_excluded"
|
|
57
|
+
if al in OWN: counts["own"]+=1
|
|
58
|
+
else: counts["excluded"]+=1
|
|
59
|
+
plan.append(rec); continue
|
|
60
|
+
if author_engaged(author, parent_id):
|
|
61
|
+
rec["decision"]="skip:author_already_engaged"; counts["engaged"]+=1; plan.append(rec); continue
|
|
62
|
+
pid = find_post_id(parent_id)
|
|
63
|
+
rec["post_id"]=pid
|
|
64
|
+
rec["decision"]="insert" if pid else "create_post+insert"
|
|
65
|
+
counts["new"]+=1
|
|
66
|
+
plan.append(rec)
|
|
67
|
+
|
|
68
|
+
json.dump(plan, open("/tmp/li_plan.json","w"), indent=2)
|
|
69
|
+
print("COUNTS:", counts)
|
|
70
|
+
print("TOTAL inspected:", len(items))
|
|
71
|
+
print()
|
|
72
|
+
for r in plan:
|
|
73
|
+
if r["decision"].startswith("skip"): continue
|
|
74
|
+
print(f"[{r['decision']}] {r['author']} | ns={r['ns']} parent={r['parent_id']} post_id={r['post_id']}")
|
|
75
|
+
print(f" urn: {r['comment_urn']}")
|
|
76
|
+
print(f" snip: {r['snippet'][:120]}")
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import json, re, subprocess, os, sys
|
|
3
|
+
|
|
4
|
+
REPO = os.path.expanduser("~/social-autoposter")
|
|
5
|
+
LID = os.path.join(REPO, "scripts", "li_discovery.py")
|
|
6
|
+
|
|
7
|
+
EXCLUDED_AUTHORS = {"louis030195", "louis3195"}
|
|
8
|
+
OWN = {"matthew diakonov", "m13v"}
|
|
9
|
+
|
|
10
|
+
def run(args):
|
|
11
|
+
r = subprocess.run([sys.executable, LID] + args, capture_output=True, text=True)
|
|
12
|
+
return (r.stdout or "").strip(), (r.stderr or "").strip(), r.returncode
|
|
13
|
+
|
|
14
|
+
# one context dump
|
|
15
|
+
ctx_out, ctx_err, rc = run(["context"])
|
|
16
|
+
ctx = json.loads(ctx_out) if ctx_out else {}
|
|
17
|
+
existing = set(ctx.get("existing_comment_ids") or [])
|
|
18
|
+
engaged_pairs = ctx.get("engaged_pairs") or []
|
|
19
|
+
posts = ctx.get("posts") or []
|
|
20
|
+
|
|
21
|
+
PARENT_RE = re.compile(r"urn:li:(?:activity|ugcPost|share):(\d+)")
|
|
22
|
+
|
|
23
|
+
# build parent_id -> post_id map
|
|
24
|
+
parent_to_post = {}
|
|
25
|
+
for p in posts:
|
|
26
|
+
u = p.get("our_url") or ""
|
|
27
|
+
m = PARENT_RE.search(u)
|
|
28
|
+
if m:
|
|
29
|
+
parent_to_post.setdefault(m.group(1), p["id"])
|
|
30
|
+
|
|
31
|
+
# build engaged set (author_lower, parent_id)
|
|
32
|
+
engaged_set = set()
|
|
33
|
+
for pair in engaged_pairs:
|
|
34
|
+
if "|||" not in pair:
|
|
35
|
+
continue
|
|
36
|
+
author, url = pair.split("|||", 1)
|
|
37
|
+
m = PARENT_RE.search(url)
|
|
38
|
+
if m:
|
|
39
|
+
engaged_set.add((author.strip().lower(), m.group(1)))
|
|
40
|
+
|
|
41
|
+
CU_RE = re.compile(r"\((?:activity|ugcPost|share):(\d+),(\d+)\)")
|
|
42
|
+
|
|
43
|
+
data = json.load(open("/tmp/li_notifs.json"))
|
|
44
|
+
|
|
45
|
+
counts = dict(scanned=len(data), new=0, already=0, engaged=0, excluded=0, own=0, no_urn=0)
|
|
46
|
+
new_items = []
|
|
47
|
+
|
|
48
|
+
def proj_for(snippet):
|
|
49
|
+
s = (snippet or "").lower()
|
|
50
|
+
# our niche is claude code / ai agents -> fazm flagship
|
|
51
|
+
if any(k in s for k in ["claude code", "claude.md", "agent", "context window", "mcp", "subagent", "harness", "codex", "anthropic", "llm", "ai "]):
|
|
52
|
+
return "fazm"
|
|
53
|
+
return "general"
|
|
54
|
+
|
|
55
|
+
for it in data:
|
|
56
|
+
cu = it.get("comment_urn")
|
|
57
|
+
author = (it.get("author") or "").strip()
|
|
58
|
+
if not cu:
|
|
59
|
+
counts["no_urn"] += 1
|
|
60
|
+
continue
|
|
61
|
+
m = CU_RE.search(cu)
|
|
62
|
+
if not m:
|
|
63
|
+
counts["no_urn"] += 1
|
|
64
|
+
continue
|
|
65
|
+
parent_id = m.group(1)
|
|
66
|
+
al = author.lower()
|
|
67
|
+
# exclusion
|
|
68
|
+
if al in OWN or author in ("unknown",):
|
|
69
|
+
counts["own"] += 1
|
|
70
|
+
continue
|
|
71
|
+
if al in EXCLUDED_AUTHORS or any(x in al for x in EXCLUDED_AUTHORS):
|
|
72
|
+
counts["excluded"] += 1
|
|
73
|
+
continue
|
|
74
|
+
if cu in existing:
|
|
75
|
+
counts["already"] += 1
|
|
76
|
+
continue
|
|
77
|
+
if (al, parent_id) in engaged_set:
|
|
78
|
+
counts["engaged"] += 1
|
|
79
|
+
continue
|
|
80
|
+
# find or create post
|
|
81
|
+
post_id = parent_to_post.get(parent_id)
|
|
82
|
+
if not post_id:
|
|
83
|
+
proj = proj_for(it.get("snippet"))
|
|
84
|
+
out, err, rc = run(["create-post", "--activity-id", parent_id, "--project", proj, "--author", author])
|
|
85
|
+
post_id = out.strip().splitlines()[-1] if out.strip() else ""
|
|
86
|
+
if not post_id:
|
|
87
|
+
print(f" [create-post FAILED parent={parent_id}] err={err}", file=sys.stderr)
|
|
88
|
+
continue
|
|
89
|
+
parent_to_post[parent_id] = post_id
|
|
90
|
+
# insert reply
|
|
91
|
+
out, err, rc = run([
|
|
92
|
+
"insert-reply", "--post-id", str(post_id),
|
|
93
|
+
"--comment-urn", cu, "--author", author,
|
|
94
|
+
"--content", (it.get("snippet") or "")[:3000],
|
|
95
|
+
"--href", it.get("href") or "",
|
|
96
|
+
])
|
|
97
|
+
res = out.strip().splitlines()[-1] if out.strip() else ""
|
|
98
|
+
if res == "duplicate":
|
|
99
|
+
counts["already"] += 1
|
|
100
|
+
elif res.startswith("gated"):
|
|
101
|
+
counts["engaged"] += 1 # gated by blocklist/velocity, not actionable
|
|
102
|
+
print(f" [gated] {author} parent={parent_id} -> {res}", file=sys.stderr)
|
|
103
|
+
elif res:
|
|
104
|
+
counts["new"] += 1
|
|
105
|
+
new_items.append((res, author, parent_id, cu))
|
|
106
|
+
# mark as existing to dedup within this run
|
|
107
|
+
existing.add(cu)
|
|
108
|
+
engaged_set.add((al, parent_id))
|
|
109
|
+
else:
|
|
110
|
+
print(f" [insert FAILED] {author} parent={parent_id} err={err}", file=sys.stderr)
|
|
111
|
+
|
|
112
|
+
print("\n=== NEW REPLIES INSERTED ===")
|
|
113
|
+
for rid, author, parent, cu in new_items:
|
|
114
|
+
print(f" reply_id={rid} author={author} parent={parent}")
|
|
115
|
+
|
|
116
|
+
print("\n=== SUMMARY ===")
|
|
117
|
+
print(f"New replies discovered: {counts['new']}")
|
|
118
|
+
print(f"Already tracked: {counts['already']}")
|
|
119
|
+
print(f"Author already engaged thread: {counts['engaged']}")
|
|
120
|
+
print(f"Excluded: {counts['excluded']}")
|
|
121
|
+
print(f"Own account: {counts['own']}")
|
|
122
|
+
print(f"No comment URN: {counts['no_urn']}")
|
|
123
|
+
print(f"Total scanned: {counts['scanned']}")
|
|
124
|
+
|
|
125
|
+
excl_total = counts["excluded"] + counts["own"]
|
|
126
|
+
print(f"\nLINKEDIN_SCAN_SUMMARY: scanned={counts['scanned']} new={counts['new']} already={counts['already']} excluded={excl_total} unmatched={counts['no_urn']}")
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Throwaway helper: run dm_conversation.py set-icp-precheck for every project.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python3 _run_icp_precheck.py --dm-id 4583 --default-label icp_miss \
|
|
6
|
+
--default-notes "crypto researcher, not target vertical" \
|
|
7
|
+
--override 'fazm=icp_miss=engaged on agentic token cost but no claude-code-wrapper signal' \
|
|
8
|
+
--override 'Agora=icp_miss=crypto researcher not a protocol governance buyer'
|
|
9
|
+
|
|
10
|
+
Any project NOT overridden gets the default label/notes.
|
|
11
|
+
"""
|
|
12
|
+
import argparse, subprocess, sys
|
|
13
|
+
|
|
14
|
+
PROJECTS = [
|
|
15
|
+
"fazm", "Terminator", "macOS MCP", "Vipassana", "S4L", "AI Browser Profile",
|
|
16
|
+
"WhatsApp MCP", "macOS Session Replay", "Cyrano", "Assrt", "PieLine", "Clone",
|
|
17
|
+
"mk0r", "fde10x", "claude-meter", "c0nsl", "tenxats", "paperback-expert",
|
|
18
|
+
"studyly", "Mediar", "NightOwl", "Runner", "Agora", "Podlog", "ccmd",
|
|
19
|
+
]
|
|
20
|
+
SCRIPT = "/Users/matthewdi/social-autoposter/scripts/dm_conversation.py"
|
|
21
|
+
|
|
22
|
+
def main():
|
|
23
|
+
ap = argparse.ArgumentParser()
|
|
24
|
+
ap.add_argument("--dm-id", required=True)
|
|
25
|
+
ap.add_argument("--default-label", default="icp_miss")
|
|
26
|
+
ap.add_argument("--default-notes", default="not target vertical")
|
|
27
|
+
ap.add_argument("--override", action="append", default=[],
|
|
28
|
+
help="PROJECT=LABEL=NOTES")
|
|
29
|
+
args = ap.parse_args()
|
|
30
|
+
|
|
31
|
+
overrides = {}
|
|
32
|
+
for o in args.override:
|
|
33
|
+
parts = o.split("=", 2)
|
|
34
|
+
if len(parts) != 3:
|
|
35
|
+
print("bad override:", o); sys.exit(2)
|
|
36
|
+
overrides[parts[0]] = (parts[1], parts[2])
|
|
37
|
+
|
|
38
|
+
# validate override keys
|
|
39
|
+
for k in overrides:
|
|
40
|
+
if k not in PROJECTS:
|
|
41
|
+
print("UNKNOWN project in override:", k); sys.exit(2)
|
|
42
|
+
|
|
43
|
+
fails = 0
|
|
44
|
+
for p in PROJECTS:
|
|
45
|
+
label, notes = overrides.get(p, (args.default_label, args.default_notes))
|
|
46
|
+
r = subprocess.run(
|
|
47
|
+
["python3", SCRIPT, "set-icp-precheck", "--dm-id", args.dm_id,
|
|
48
|
+
"--project", p, "--label", label, "--notes", notes],
|
|
49
|
+
capture_output=True, text=True)
|
|
50
|
+
tag = "ok" if r.returncode == 0 else "FAIL"
|
|
51
|
+
if r.returncode != 0:
|
|
52
|
+
fails += 1
|
|
53
|
+
print(f"{tag} {p}={label}", (r.stderr or r.stdout or "").strip()[:120])
|
|
54
|
+
print(f"DONE dm={args.dm_id} fails={fails}")
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
main()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# exec'd inside browser-harness; helpers (goto_url, js, click_at_xy, type_text,
|
|
2
|
+
# press_key, page_info, wait_for_load) are available as globals.
|
|
3
|
+
import time as _t, json as _j
|
|
4
|
+
def send(A, MSG, UNIQ):
|
|
5
|
+
goto_url("https://x.com/"+A); wait_for_load(); _t.sleep(2.5)
|
|
6
|
+
prof = js(r"""
|
|
7
|
+
(() => {
|
|
8
|
+
const nameEl=document.querySelector('[data-testid="UserName"]');
|
|
9
|
+
const bioEl=document.querySelector('[data-testid="UserDescription"]');
|
|
10
|
+
let followers=null;
|
|
11
|
+
document.querySelectorAll('a[href$="/verified_followers"],a[href$="/followers"]').forEach(a=>{followers=a.innerText.replace(/\n/g,' ');});
|
|
12
|
+
const tweets=[...document.querySelectorAll('[data-testid="tweetText"]')].slice(0,3).map(t=>t.innerText.slice(0,120).replace(/\n/g,' '));
|
|
13
|
+
const b=document.querySelector('[data-testid="sendDMFromProfile"]');
|
|
14
|
+
let msgRect=null; if(b){const r=b.getBoundingClientRect(); msgRect={x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};}
|
|
15
|
+
const suspended=/This account doesn|Account suspended|Hmm.*went wrong|Caution/i.test(document.body.innerText.slice(0,300));
|
|
16
|
+
return {name:nameEl?nameEl.innerText.replace(/\n/g,' | '):null, bio:bioEl?bioEl.innerText.replace(/\n/g,' '):null, followers, tweets, hasMsg:!!b, msgRect, suspended};
|
|
17
|
+
})()
|
|
18
|
+
""")
|
|
19
|
+
prof['tweets']=(prof.get('tweets') or [])[:1]
|
|
20
|
+
prof['bio']=(prof.get('bio') or '')[:140]
|
|
21
|
+
out={"prof":prof}
|
|
22
|
+
if prof.get('suspended') or not prof.get('hasMsg'):
|
|
23
|
+
out["status"]="no_dm"; print(_j.dumps(out,ensure_ascii=True)[:2400]); return
|
|
24
|
+
click_at_xy(prof['msgRect']['x'],prof['msgRect']['y']); _t.sleep(3)
|
|
25
|
+
rect=js(r"""(()=>{const t=document.querySelector('[data-testid="dm-composer-textarea"]'); if(!t)return null; const r=t.getBoundingClientRect(); return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};})()""")
|
|
26
|
+
if not rect:
|
|
27
|
+
out["status"]="no_composer"; out["url"]=page_info().get('url'); print(_j.dumps(out,ensure_ascii=True)[:2400]); return
|
|
28
|
+
click_at_xy(rect['x'],rect['y']); _t.sleep(0.6)
|
|
29
|
+
type_text(MSG); _t.sleep(0.9)
|
|
30
|
+
val=js(r"""(()=>{const t=document.querySelector('[data-testid="dm-composer-textarea"]'); return t?t.value:null;})()""")
|
|
31
|
+
if (val or "").strip()!=MSG:
|
|
32
|
+
out["status"]="type_mismatch"; out["len"]=len(val or ""); out["url"]=page_info().get('url'); print(_j.dumps(out,ensure_ascii=True)[:2400]); return
|
|
33
|
+
press_key("Enter"); _t.sleep(2.6)
|
|
34
|
+
chk=js(r"""(()=>{const t=document.querySelector('[data-testid="dm-composer-textarea"]'); const body=document.body.innerText; return {cleared:t?t.value.trim()==='':null, url:location.href, bodyHasHandle:body.includes('@__A__')};})()""".replace('__A__',A))
|
|
35
|
+
full=js(r"""(()=>document.body.innerText)()""") or ""
|
|
36
|
+
chk["hasPhrase"]= UNIQ in full
|
|
37
|
+
out["status"]="sent" if (chk.get('cleared') and chk.get('hasPhrase')) else "send_unverified"
|
|
38
|
+
out["url"]=chk.get('url'); out["verify"]=chk
|
|
39
|
+
print(_j.dumps(out,ensure_ascii=True)[:2400])
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Bulk-record ICP prechecks for one dm in a single process.
|
|
3
|
+
Usage: bulk_icp.py DM_ID 'project=label:notes' 'project2=label2:notes2' ...
|
|
4
|
+
label in {icp_match,icp_miss,disqualified,unknown}
|
|
5
|
+
"""
|
|
6
|
+
import sys, subprocess
|
|
7
|
+
dm_id=sys.argv[1]
|
|
8
|
+
for arg in sys.argv[2:]:
|
|
9
|
+
proj, rest = arg.split('=',1)
|
|
10
|
+
if ':' in rest:
|
|
11
|
+
label, notes = rest.split(':',1)
|
|
12
|
+
else:
|
|
13
|
+
label, notes = rest, ''
|
|
14
|
+
cmd=['python3','scripts/dm_conversation.py','set-icp-precheck','--dm-id',dm_id,
|
|
15
|
+
'--project',proj,'--label',label]
|
|
16
|
+
if notes: cmd += ['--notes',notes]
|
|
17
|
+
r=subprocess.run(cmd,capture_output=True,text=True)
|
|
18
|
+
print(proj,label,'->',('ok' if r.returncode==0 else 'ERR '+r.stderr.strip()[:80]))
|
|
@@ -203,9 +203,47 @@ def main():
|
|
|
203
203
|
persisted = 0
|
|
204
204
|
with_media = 0
|
|
205
205
|
reposts = 0
|
|
206
|
+
access_checked = 0
|
|
207
|
+
access_not_visible = 0
|
|
208
|
+
access_check_limit = int(os.environ.get("SAPS_TWITTER_EMPTY_MEDIA_ACCESS_CHECKS", "3"))
|
|
209
|
+
access_wait_ms = int(os.environ.get("SAPS_TWITTER_EMPTY_MEDIA_ACCESS_WAIT_MS", "4000"))
|
|
210
|
+
empty_capture_unreliable = None
|
|
206
211
|
for cid, url in pairs:
|
|
207
212
|
rec = by_url.get(url) or {}
|
|
208
213
|
media = rec.get("media", [])
|
|
214
|
+
access = None
|
|
215
|
+
if not media:
|
|
216
|
+
# Empty media is only meaningful when the tweet itself rendered.
|
|
217
|
+
# If x.com served an empty app shell / block / protected page, do
|
|
218
|
+
# NOT persist [] because [] means "captured successfully, no media";
|
|
219
|
+
# leaving NULL lets a later cycle retry instead of poisoning the row.
|
|
220
|
+
if empty_capture_unreliable:
|
|
221
|
+
access = empty_capture_unreliable
|
|
222
|
+
elif access_checked < access_check_limit:
|
|
223
|
+
try:
|
|
224
|
+
from twitter_access_check import diagnose_tweet_access
|
|
225
|
+
access = diagnose_tweet_access(
|
|
226
|
+
url, wait_ms=access_wait_ms, include_public=False,
|
|
227
|
+
)
|
|
228
|
+
access_checked += 1
|
|
229
|
+
except Exception as e:
|
|
230
|
+
access = {"status": "access_check_failed", "reason": str(e)}
|
|
231
|
+
access_checked += 1
|
|
232
|
+
else:
|
|
233
|
+
access = {"status": "unchecked", "reason": "empty_media_access_check_cap"}
|
|
234
|
+
status = access.get("status")
|
|
235
|
+
if status not in ("visible", "visible_no_anchor", "unchecked"):
|
|
236
|
+
access_not_visible += 1
|
|
237
|
+
if status in ("app_not_hydrated", "app_error", "logged_out", "access_check_failed", "unknown"):
|
|
238
|
+
empty_capture_unreliable = access
|
|
239
|
+
print(
|
|
240
|
+
f"[capture_thread_media] cid={cid} url={url} "
|
|
241
|
+
f"access_status={status} reason={access.get('reason')} "
|
|
242
|
+
"leaving thread_media NULL",
|
|
243
|
+
file=sys.stderr,
|
|
244
|
+
)
|
|
245
|
+
captured.append((cid, media, {"is_repost": False, "reposted_by": ""}))
|
|
246
|
+
continue
|
|
209
247
|
fresh = rec.get("repost", {"is_repost": False, "reposted_by": ""})
|
|
210
248
|
# Authoritative repost flag comes from discovery (stored). Fresh permalink
|
|
211
249
|
# detection is a rare bonus; prefer stored, fall back to fresh.
|
|
@@ -240,6 +278,8 @@ def main():
|
|
|
240
278
|
"persisted": persisted,
|
|
241
279
|
"with_media": with_media,
|
|
242
280
|
"reposts": reposts,
|
|
281
|
+
"access_checked": access_checked,
|
|
282
|
+
"access_not_visible": access_not_visible,
|
|
243
283
|
"urls_visited": (batch or {}).get("urls_visited", 0),
|
|
244
284
|
}), file=sys.stderr)
|
|
245
285
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Health check for the browser session-lock fix (2026-06-16).
|
|
3
|
+
# Full context: docs/twitter_browser_lock.md. Logic test: scripts/test_browser_lock.py.
|
|
4
|
+
#
|
|
5
|
+
# Prints [ok ] / [BAD] / [i ] lines so you can tell at a glance whether the fix is
|
|
6
|
+
# present in code AND behaving in production. Exits non-zero if any [BAD] is found.
|
|
7
|
+
#
|
|
8
|
+
# bash scripts/check_browser_lock_health.sh [HOURS] # default lookback 24h
|
|
9
|
+
#
|
|
10
|
+
# What it checks (see docs §4/§5 for the why):
|
|
11
|
+
# 1. fix still present in twitter_browser.py + linkedin_browser.py (catch a revert)
|
|
12
|
+
# 2. defect-b `rm -f` has NOT crept back into skill/*.sh
|
|
13
|
+
# 3. reclaim markers firing = fix actively catching dead holders (positive signal)
|
|
14
|
+
# 4. starvation giveups WITHOUT the peer-alive tell = defect (a) recurring (BAD)
|
|
15
|
+
# 5. shell-lock trap_rm owner=OTHER = a pipeline deleted a LIVE peer's lock (BAD)
|
|
16
|
+
set -u
|
|
17
|
+
cd "$(dirname "$0")/.."
|
|
18
|
+
LOGS="skill/logs"
|
|
19
|
+
HOURS="${1:-24}"
|
|
20
|
+
bad=0
|
|
21
|
+
|
|
22
|
+
# Dated per-run logs that are BOTH within the lookback window AND newer than the lock
|
|
23
|
+
# code file. The `-newer` clause is the key: it counts only runs that started after the
|
|
24
|
+
# fix (or, if the fix is ever reverted, after that revert) -- so day-one pre-fix
|
|
25
|
+
# starvation noise is excluded automatically, and a real regression still surfaces.
|
|
26
|
+
# (NOT launchd-*.log: those are append-only, so their mtime says nothing about when a
|
|
27
|
+
# line inside was written.)
|
|
28
|
+
recent=$(find "$LOGS" -maxdepth 1 -name '*2026-*.log' -mmin "-$((HOURS*60))" \
|
|
29
|
+
-newer scripts/twitter_browser.py 2>/dev/null | grep -v 'launchd-' || true)
|
|
30
|
+
[ -z "$recent" ] && recent="/dev/null" # guard: never let grep read stdin
|
|
31
|
+
|
|
32
|
+
echo "== browser-lock health (last ${HOURS}h of dated per-run logs) =="
|
|
33
|
+
|
|
34
|
+
# 1. fix present in code
|
|
35
|
+
if grep -q _is_python_holder_alive scripts/twitter_browser.py 2>/dev/null \
|
|
36
|
+
&& grep -q _is_python_holder_alive scripts/linkedin_browser.py 2>/dev/null; then
|
|
37
|
+
echo "[ok ] fix present (twitter_browser.py + linkedin_browser.py)"
|
|
38
|
+
else
|
|
39
|
+
echo "[BAD] fix MISSING from code -> reverted (re-apply from docs/twitter_browser_lock.md)"; bad=1
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
# 2. defect-b rm -f gone from shells (anchored so the explanatory comment is not a hit).
|
|
43
|
+
# Scope to *.sh only -- never recurse skill/ (it holds a huge claude-sessions/ tree).
|
|
44
|
+
if grep -hEq '^[[:space:]]*rm -f .*twitter-browser-lock\.json' skill/*.sh skill/lib/*.sh 2>/dev/null; then
|
|
45
|
+
echo "[BAD] defect-b: an actual 'rm -f ...twitter-browser-lock.json' is back in skill/*.sh"; bad=1
|
|
46
|
+
else
|
|
47
|
+
echo "[ok ] no rm -f of the session lock in skill/*.sh"
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
# 3. positive: reclaim markers (each = a dead holder caught that USED to starve the fleet)
|
|
51
|
+
rec=$(grep -hoE '\[browser_lock\] reclaimed .*reason=[a-z_]+' $recent 2>/dev/null | wc -l | tr -d ' ')
|
|
52
|
+
recd=$(grep -hoE '\[browser_lock\] reclaimed .*reason=dead_python' $recent 2>/dev/null | wc -l | tr -d ' ')
|
|
53
|
+
echo "[i ] reclaim markers fired: ${rec:-0} (of which dead_python: ${recd:-0}) -- 0 is fine if nothing crashed"
|
|
54
|
+
|
|
55
|
+
# 4. starvation: twitter giveup without 'peer alive' / linkedin profile_locked without 'peer_alive'
|
|
56
|
+
bad_tw=$(grep -hE 'locked by session .* giving up' $recent 2>/dev/null | grep -vc 'peer alive' || true)
|
|
57
|
+
bad_li=$(grep -hE 'profile_locked' $recent 2>/dev/null | grep -vc 'peer_alive' || true)
|
|
58
|
+
bad_tw=${bad_tw:-0}; bad_li=${bad_li:-0}
|
|
59
|
+
if [ "$bad_tw" -gt 0 ] || [ "$bad_li" -gt 0 ]; then
|
|
60
|
+
echo "[BAD] old-format starvation giveups: twitter=$bad_tw linkedin=$bad_li (defect a recurring?)"; bad=1
|
|
61
|
+
else
|
|
62
|
+
echo "[ok ] no old-format starvation giveups (twitter + linkedin)"
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
# 5. shell-lock: dangerous trap_rm owner=OTHER (deleting a live peer's shell lock).
|
|
66
|
+
# NOTE: 'event=stale_reclaim ... owner=OTHER' is LEGITIMATE (reclaiming a dead holder),
|
|
67
|
+
# only 'event=trap_rm ... owner=OTHER' is the bad one.
|
|
68
|
+
if [ -f "$LOGS/lock-events.log" ]; then
|
|
69
|
+
to=$(grep -cE 'event=trap_rm .*owner=OTHER' "$LOGS/lock-events.log" 2>/dev/null); to=${to:-0}
|
|
70
|
+
if [ "$to" -gt 0 ]; then
|
|
71
|
+
echo "[BAD] shell trap_rm owner=OTHER x$to (a pipeline deleted a LIVE peer's shell lock)"; bad=1
|
|
72
|
+
else
|
|
73
|
+
echo "[ok ] shell-lock: no trap_rm owner=OTHER (live-lock deletes)"
|
|
74
|
+
fi
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
echo "=================================================="
|
|
78
|
+
if [ "$bad" -ne 0 ]; then
|
|
79
|
+
echo "RESULT: ATTENTION NEEDED (see [BAD] above)"; exit 1
|
|
80
|
+
fi
|
|
81
|
+
echo "RESULT: HEALTHY"
|