@m13v/s4l 1.7.5 → 1.7.6-rc.1
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/package.json +1 -1
- package/package.json +1 -1
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_process_li_notifs.py +91 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/claude_job.py +14 -9
- package/scripts/draft_prompt_core.py +736 -0
- package/scripts/log_draft.py +38 -3
- package/scripts/post_reddit.py +94 -136
- package/scripts/reddit_tools.py +9 -0
- package/scripts/test_draft_prompt_core.py +255 -0
- package/skill/audit.sh +12 -23
- package/skill/dm-outreach-twitter.sh +3 -1
- package/skill/engage-dm-replies.sh +5 -2
- package/skill/engage-linkedin.sh +1 -1
- package/skill/engage-twitter.sh +4 -2
- package/skill/refresh-twitter-following.sh +3 -1
- package/skill/run-twitter-cycle.sh +58 -403
- package/skill/run-twitter-threads.sh +3 -1
- package/skill/scan-twitter-followups.sh +3 -1
- package/skill/stats.sh +9 -1
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.6-rc.1",
|
|
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": {
|
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.6-rc.1",
|
|
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,58 @@
|
|
|
1
|
+
import os, re, json, subprocess, glob
|
|
2
|
+
REPO = os.path.expanduser("~/social-autoposter"); os.chdir(REPO)
|
|
3
|
+
all_py = {os.path.basename(p) for p in glob.glob("scripts/*.py")}
|
|
4
|
+
|
|
5
|
+
entry_surfaces = glob.glob("skill/*.sh") + glob.glob("skill/lib/*.sh")
|
|
6
|
+
entry_surfaces += ["SKILL.md", "setup/SKILL.md", "bin/cli.js"]
|
|
7
|
+
entry_surfaces += glob.glob("mcp/dist/*.js") + glob.glob("mcp/*.mjs")
|
|
8
|
+
|
|
9
|
+
ref_re = re.compile(r"scripts/([A-Za-z0-9_]+)\.py")
|
|
10
|
+
def refs_in_text(txt):
|
|
11
|
+
return {m+".py" for m in ref_re.findall(txt) if (m+".py") in all_py}
|
|
12
|
+
|
|
13
|
+
entries, surface_hits = set(), {}
|
|
14
|
+
for s in entry_surfaces:
|
|
15
|
+
if not os.path.exists(s): continue
|
|
16
|
+
txt = open(s, encoding="utf-8", errors="ignore").read()
|
|
17
|
+
# strip // comments for js, # comments for md is harder; keep simple: count all refs but mark cli.js comment lines
|
|
18
|
+
for fn in refs_in_text(txt):
|
|
19
|
+
entries.add(fn); surface_hits.setdefault(fn,set()).add(s)
|
|
20
|
+
|
|
21
|
+
imp_res = [re.compile(r"^\s*import\s+([A-Za-z0-9_]+)", re.M),
|
|
22
|
+
re.compile(r"^\s*from\s+([A-Za-z0-9_]+)\s+import", re.M),
|
|
23
|
+
re.compile(r"^\s*from\s+scripts\s+import\s+([A-Za-z0-9_,\s]+)", re.M),
|
|
24
|
+
re.compile(r"^\s*from\s+scripts\.([A-Za-z0-9_]+)\s+import", re.M),
|
|
25
|
+
re.compile(r"^\s*import\s+scripts\.([A-Za-z0-9_]+)", re.M)]
|
|
26
|
+
def expand(fn):
|
|
27
|
+
path=os.path.join("scripts",fn)
|
|
28
|
+
if not os.path.exists(path): return set()
|
|
29
|
+
txt=open(path,encoding="utf-8",errors="ignore").read()
|
|
30
|
+
found=set()
|
|
31
|
+
for rx in imp_res:
|
|
32
|
+
for g in rx.findall(txt):
|
|
33
|
+
for name in re.split(r"[,\s]+",g):
|
|
34
|
+
name=name.strip()
|
|
35
|
+
if name and (name+".py") in all_py: found.add(name+".py")
|
|
36
|
+
found |= refs_in_text(txt) # NEW: intra-python subprocess scripts/X.py refs
|
|
37
|
+
# follow symlink targets too (update_stats.py -> stats.py)
|
|
38
|
+
if os.path.islink(path):
|
|
39
|
+
tgt=os.path.basename(os.readlink(path))
|
|
40
|
+
if tgt in all_py: found.add(tgt)
|
|
41
|
+
return found
|
|
42
|
+
|
|
43
|
+
closure=set(entries); stack=list(entries)
|
|
44
|
+
while stack:
|
|
45
|
+
for d in expand(stack.pop()):
|
|
46
|
+
if d not in closure: closure.add(d); stack.append(d)
|
|
47
|
+
|
|
48
|
+
out=subprocess.run(["npm","pack","--dry-run","--json"],capture_output=True,text=True)
|
|
49
|
+
shipped=sorted(os.path.basename(f["path"]) for f in json.loads(out.stdout)[0]["files"] if f["path"].startswith("scripts/") and f["path"].endswith(".py"))
|
|
50
|
+
drop=sorted(set(shipped)-closure); keep=sorted(set(shipped)&closure)
|
|
51
|
+
print("entries:",len(entries),"| closure:",len(closure),"| shipped:",len(shipped))
|
|
52
|
+
print(f"\n=== KEEP (shipped & needed): {len(keep)}")
|
|
53
|
+
print(f"=== DROP (shipped but unreferenced anywhere consumer): {len(drop)}")
|
|
54
|
+
for d in drop:
|
|
55
|
+
if d=="_compute_allowlist.py": continue
|
|
56
|
+
print(" -",d)
|
|
57
|
+
open("/tmp/keep.txt","w").write("\n".join(k for k in keep if k!="_compute_allowlist.py"))
|
|
58
|
+
open("/tmp/drop.txt","w").write("\n".join(d for d in drop if d!="_compute_allowlist.py"))
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Scratch driver: read JSON list of {post_id, session, text} from argv[1] and
|
|
3
|
+
run link_edit_helper mark-edited + dm_short_links backfill-post for each.
|
|
4
|
+
Gitignored scratch helper for the reddit link-edit run."""
|
|
5
|
+
import json, subprocess, sys, os
|
|
6
|
+
|
|
7
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
8
|
+
items = json.load(open(sys.argv[1]))
|
|
9
|
+
for it in items:
|
|
10
|
+
pid = str(it["post_id"]); sess = it["session"]; text = it["text"]
|
|
11
|
+
src = it.get("source", "plain_url_ab_skip")
|
|
12
|
+
r1 = subprocess.run([sys.executable, os.path.join(HERE, "link_edit_helper.py"),
|
|
13
|
+
"mark-edited", "--post-id", pid, "--content", text, "--source", src],
|
|
14
|
+
capture_output=True, text=True)
|
|
15
|
+
r2 = subprocess.run([sys.executable, os.path.join(HERE, "dm_short_links.py"),
|
|
16
|
+
"backfill-post", "--minted-session", sess, "--post-id", pid],
|
|
17
|
+
capture_output=True, text=True)
|
|
18
|
+
bf = (r2.stdout or "").strip().splitlines()[-1:] or [""]
|
|
19
|
+
print(f"post {pid}: mark_edited_rc={r1.returncode} backfill={bf[0]}"
|
|
20
|
+
+ (f" ERR1={r1.stderr.strip()}" if r1.returncode else ""))
|
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,91 @@
|
|
|
1
|
+
import json, re, urllib.parse
|
|
2
|
+
|
|
3
|
+
NOTIFS = "/tmp/li_notifs.json"
|
|
4
|
+
CID = "/Users/matthewdi/.claude/projects/-/f0a4ef0d-11f2-4618-85e2-bf62ad39cea2/tool-results/brzcccx3h.txt"
|
|
5
|
+
PAIRS = "/Users/matthewdi/.claude/projects/-/f0a4ef0d-11f2-4618-85e2-bf62ad39cea2/tool-results/bu0y0bphg.txt"
|
|
6
|
+
POSTS = "/Users/matthewdi/.claude/projects/-/f0a4ef0d-11f2-4618-85e2-bf62ad39cea2/tool-results/bnb5ginhc.txt"
|
|
7
|
+
|
|
8
|
+
def load_lines(p):
|
|
9
|
+
out=[]
|
|
10
|
+
for ln in open(p, encoding="utf-8", errors="replace"):
|
|
11
|
+
ln=ln.rstrip("\n")
|
|
12
|
+
# skip persisted-output wrapper lines
|
|
13
|
+
if not ln or ln.startswith("<") or ln.startswith("Output too large") or ln.startswith("Preview") or ln.startswith("..."):
|
|
14
|
+
continue
|
|
15
|
+
out.append(ln)
|
|
16
|
+
return out
|
|
17
|
+
|
|
18
|
+
comment_ids = set(load_lines(CID))
|
|
19
|
+
pairs = set(load_lines(PAIRS))
|
|
20
|
+
posts = load_lines(POSTS)
|
|
21
|
+
|
|
22
|
+
# activity number -> post id
|
|
23
|
+
post_by_act = {}
|
|
24
|
+
for ln in posts:
|
|
25
|
+
if "|" not in ln: continue
|
|
26
|
+
pid, url = ln.split("|",1)
|
|
27
|
+
for m in re.findall(r"urn:li:(?:activity|share|ugcPost):(\d+)", url):
|
|
28
|
+
post_by_act[m]=pid
|
|
29
|
+
|
|
30
|
+
# config projects search_topics
|
|
31
|
+
cfg = json.load(open("/Users/matthewdi/social-autoposter/config.json"))
|
|
32
|
+
projtopics = []
|
|
33
|
+
for pr in cfg.get("projects",[]):
|
|
34
|
+
projtopics.append((pr["name"], [t.lower() for t in pr.get("search_topics",[])]))
|
|
35
|
+
|
|
36
|
+
EXCLUDED = {"louis030195","louis3195"}
|
|
37
|
+
OWN = {"matthew diakonov","m13v"}
|
|
38
|
+
|
|
39
|
+
def match_project(snippet):
|
|
40
|
+
s = snippet.lower()
|
|
41
|
+
best=None; bestscore=0
|
|
42
|
+
for name, topics in projtopics:
|
|
43
|
+
score=0
|
|
44
|
+
for t in topics:
|
|
45
|
+
if t and t in s:
|
|
46
|
+
score+=1
|
|
47
|
+
if score>bestscore:
|
|
48
|
+
bestscore=score; best=name
|
|
49
|
+
return best or "S4L"
|
|
50
|
+
|
|
51
|
+
data = json.load(open(NOTIFS))
|
|
52
|
+
decisions=[]
|
|
53
|
+
for d in data:
|
|
54
|
+
href = d["href"]
|
|
55
|
+
dec = urllib.parse.unquote(href)
|
|
56
|
+
m = re.search(r"urn:li:activity:(\d+)", dec)
|
|
57
|
+
activity_id = m.group(1) if m else None
|
|
58
|
+
comment_urn = d["comment_urn"]
|
|
59
|
+
author = d["author"]
|
|
60
|
+
a_low = author.lower().strip()
|
|
61
|
+
rec = dict(author=author, activity_id=activity_id, comment_urn=comment_urn,
|
|
62
|
+
snippet=d["snippet"], href=href, type=d["type"])
|
|
63
|
+
if not comment_urn or not activity_id:
|
|
64
|
+
rec["decision"]="no_comment_urn"; decisions.append(rec); continue
|
|
65
|
+
if a_low in OWN:
|
|
66
|
+
rec["decision"]="own_account"; decisions.append(rec); continue
|
|
67
|
+
if a_low in EXCLUDED or any(x in a_low for x in EXCLUDED):
|
|
68
|
+
rec["decision"]="excluded_author"; decisions.append(rec); continue
|
|
69
|
+
if comment_urn in comment_ids:
|
|
70
|
+
rec["decision"]="already_tracked"; decisions.append(rec); continue
|
|
71
|
+
our_url = "https://www.linkedin.com/feed/update/urn:li:activity:%s/" % activity_id
|
|
72
|
+
apk = author + "|||" + our_url
|
|
73
|
+
if apk in pairs:
|
|
74
|
+
rec["decision"]="author_already_engaged"; decisions.append(rec); continue
|
|
75
|
+
# match post
|
|
76
|
+
pid = post_by_act.get(activity_id)
|
|
77
|
+
rec["post_id"]=pid
|
|
78
|
+
rec["project"]=match_project(d["snippet"])
|
|
79
|
+
rec["decision"]="INSERT"
|
|
80
|
+
decisions.append(rec)
|
|
81
|
+
|
|
82
|
+
json.dump(decisions, open("/tmp/li_decisions.json","w"))
|
|
83
|
+
# summary
|
|
84
|
+
from collections import Counter
|
|
85
|
+
c=Counter(r["decision"] for r in decisions)
|
|
86
|
+
print("TOTAL", len(decisions))
|
|
87
|
+
for k,v in c.items(): print(k,v)
|
|
88
|
+
print("---INSERTS---")
|
|
89
|
+
for i,r in enumerate(decisions):
|
|
90
|
+
if r["decision"]=="INSERT":
|
|
91
|
+
print(i, r["author"], "| act", r["activity_id"], "| post", r.get("post_id"), "| proj", r["project"])
|
|
@@ -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()
|
package/scripts/claude_job.py
CHANGED
|
@@ -125,15 +125,20 @@ TYPE_TO_WORKER_NOTES = {
|
|
|
125
125
|
),
|
|
126
126
|
"reddit-draft": (
|
|
127
127
|
"WORKER EXECUTION NOTES (queue metadata; follow while executing the "
|
|
128
|
-
"prompt below):
|
|
129
|
-
"
|
|
130
|
-
"
|
|
131
|
-
"
|
|
132
|
-
"
|
|
133
|
-
"candidate
|
|
134
|
-
"
|
|
135
|
-
"
|
|
136
|
-
"
|
|
128
|
+
"prompt below): this unattended session is terminated ~90 seconds "
|
|
129
|
+
"after your LAST tool call. Every Reddit thread's content is already "
|
|
130
|
+
"inlined in the prompt — never fetch or open a reddit.com URL; "
|
|
131
|
+
"WebSearch/WebFetch are for EXTERNAL fact-checking only, per the "
|
|
132
|
+
"prompt's THREAD CONTENT rules. Apply the prompt's SELECTION GATE to "
|
|
133
|
+
"each candidate and work ONE thread at a time: draft both texts, "
|
|
134
|
+
"then IMMEDIATELY run that thread's log_draft.py persist command "
|
|
135
|
+
"exactly as the prompt's PERSIST step specifies (a quick Bash call), "
|
|
136
|
+
"THEN move to the next. Those per-thread Bash calls keep the session "
|
|
137
|
+
"alive. Only after EVERY candidate is handled do you assemble and "
|
|
138
|
+
"submit ONE result object matching the schema: {\"posts\": [...], "
|
|
139
|
+
"\"rejects\": [...]}. A candidate that fails the gate is simply "
|
|
140
|
+
"absent from posts (add a rejects entry only for the structural "
|
|
141
|
+
"false-positive cases the prompt describes)."
|
|
137
142
|
),
|
|
138
143
|
}
|
|
139
144
|
|