@m13v/s4l 1.6.197-rc.7
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/README.md +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1314 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- 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/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +497 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- package/skill/topics.sh +51 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Log a posted comment/reply to the database.
|
|
3
|
+
|
|
4
|
+
Single tool for all platforms. Enforces:
|
|
5
|
+
- status='active' for successful posts
|
|
6
|
+
- our_url must start with http for successful posts (validated)
|
|
7
|
+
- dedup on thread_url per platform
|
|
8
|
+
|
|
9
|
+
Usage (INSERT — default mode):
|
|
10
|
+
python3 scripts/log_post.py \\
|
|
11
|
+
--platform reddit \\
|
|
12
|
+
--thread-url URL \\
|
|
13
|
+
--our-url URL \\
|
|
14
|
+
--our-content TEXT \\
|
|
15
|
+
--project PROJECT \\
|
|
16
|
+
--thread-author AUTHOR \\
|
|
17
|
+
--thread-title TITLE \\
|
|
18
|
+
[--account ACCOUNT] \\
|
|
19
|
+
[--engagement-style STYLE] \\
|
|
20
|
+
[--language LANG]
|
|
21
|
+
|
|
22
|
+
Usage (REJECTED — record a server-rejected attempt):
|
|
23
|
+
python3 scripts/log_post.py --rejected \\
|
|
24
|
+
--platform linkedin \\
|
|
25
|
+
--thread-url URL \\
|
|
26
|
+
--our-content TEXT \\
|
|
27
|
+
--project PROJECT \\
|
|
28
|
+
[--rejection-reason TEXT] \\
|
|
29
|
+
[--network-response TEXT]
|
|
30
|
+
|
|
31
|
+
Inserts with status='rejected_by_platform'. Skips our_url validation
|
|
32
|
+
(no permalink exists). Counts toward dedup so we don't retry the same
|
|
33
|
+
thread. rejection-reason and network-response go into source_summary.
|
|
34
|
+
|
|
35
|
+
Usage (UPDATE — record a self-reply / link follow-up on an existing post):
|
|
36
|
+
python3 scripts/log_post.py --mark-self-reply \\
|
|
37
|
+
--post-id 12345 \\
|
|
38
|
+
--self-reply-url URL \\
|
|
39
|
+
--self-reply-content TEXT
|
|
40
|
+
|
|
41
|
+
Writes to posts.link_edited_at / link_edit_content so the
|
|
42
|
+
link-edit-* sweeps skip this row on the next pass.
|
|
43
|
+
|
|
44
|
+
Output (JSON):
|
|
45
|
+
{"logged": true, "post_id": 12345}
|
|
46
|
+
{"rejected": true, "post_id": 12345}
|
|
47
|
+
{"marked": true, "post_id": 12345}
|
|
48
|
+
{"error": "DUPLICATE_THREAD", ...}
|
|
49
|
+
{"error": "INVALID_URL", ...}
|
|
50
|
+
{"error": "POST_NOT_FOUND", ...}
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
import argparse
|
|
54
|
+
import json
|
|
55
|
+
import os
|
|
56
|
+
import re
|
|
57
|
+
import sys
|
|
58
|
+
import urllib.parse
|
|
59
|
+
|
|
60
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
61
|
+
import http_api
|
|
62
|
+
|
|
63
|
+
# --- API error-envelope helpers (2026-06-02) -------------------------------
|
|
64
|
+
# The API returns failures as a NESTED object: {"ok": false, "error": {"code",
|
|
65
|
+
# "message", "details"}} (see social-autoposter-website response.ts). http_api
|
|
66
|
+
# may also surface a FLAT {"error": "conflict"} on a 409 it couldn't parse. The
|
|
67
|
+
# old `resp.get("error") in (...)` string check missed the nested shape, so a
|
|
68
|
+
# duplicate_thread 409 fell through and printed {"logged": true, "post_id":
|
|
69
|
+
# null} -- a false success that looked like a logging gap. These helpers read
|
|
70
|
+
# either shape so dedups are recognized and reported correctly.
|
|
71
|
+
def _api_error_code(resp):
|
|
72
|
+
e = (resp or {}).get("error")
|
|
73
|
+
if isinstance(e, dict):
|
|
74
|
+
return e.get("code")
|
|
75
|
+
return e # flat string or None
|
|
76
|
+
|
|
77
|
+
def _api_error_detail(resp, key):
|
|
78
|
+
e = (resp or {}).get("error")
|
|
79
|
+
if isinstance(e, dict):
|
|
80
|
+
d = e.get("details")
|
|
81
|
+
if isinstance(d, dict) and d.get(key) is not None:
|
|
82
|
+
return d.get(key)
|
|
83
|
+
return (resp or {}).get(key)
|
|
84
|
+
|
|
85
|
+
import linkedin_url as li_url
|
|
86
|
+
from db import load_env
|
|
87
|
+
from twitter_account import resolve_handle as resolve_twitter_handle
|
|
88
|
+
from version import read_version as read_autoposter_version
|
|
89
|
+
|
|
90
|
+
# Engagement-style enforcement (2026-05-31 LinkedIn alignment): the LinkedIn
|
|
91
|
+
# post path goes straight through log_post.py (no candidate/plan pipeline like
|
|
92
|
+
# Twitter's twitter_post_plan.py), so the picker-coercion engine has to live
|
|
93
|
+
# here. When the caller passes --assigned-style/--assigned-mode (sourced from
|
|
94
|
+
# saps_pick_style in run-linkedin.sh), we call validate_or_register exactly
|
|
95
|
+
# like twitter_post_plan.py::post_one so (a) USE-mode drift coerces back to the
|
|
96
|
+
# assigned style and (b) INVENT-mode inventions land in
|
|
97
|
+
# engagement_styles_registry via the /api/v1/engagement-styles/registry POST.
|
|
98
|
+
# Soft import so the post path still runs if the module is unavailable; we fall
|
|
99
|
+
# back to the raw --engagement-style string in that case.
|
|
100
|
+
try:
|
|
101
|
+
from engagement_styles import validate_or_register # noqa: E402
|
|
102
|
+
except Exception:
|
|
103
|
+
validate_or_register = None # type: ignore[assignment]
|
|
104
|
+
|
|
105
|
+
URN_ID_RE = re.compile(r"\b(\d{16,19})\b")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_urn_ids(*sources):
|
|
109
|
+
"""Extract all 16-19-digit URN IDs from the given strings, dedupe,
|
|
110
|
+
preserve insertion order. Used to merge --urns CLI input with IDs
|
|
111
|
+
found in thread_url / our_url so we always store the full URN set
|
|
112
|
+
we know about for a LinkedIn post."""
|
|
113
|
+
seen = []
|
|
114
|
+
for s in sources:
|
|
115
|
+
if not s:
|
|
116
|
+
continue
|
|
117
|
+
for m in URN_ID_RE.finditer(s):
|
|
118
|
+
v = m.group(1)
|
|
119
|
+
if v not in seen:
|
|
120
|
+
seen.append(v)
|
|
121
|
+
return seen
|
|
122
|
+
|
|
123
|
+
VALID_PLATFORMS = ("reddit", "twitter", "linkedin", "github_issues", "moltbook")
|
|
124
|
+
|
|
125
|
+
# Maps log_post's platform strings to account_resolver's platform keys.
|
|
126
|
+
# (log_post uses "github_issues"; account_resolver uses "github".)
|
|
127
|
+
_RESOLVER_PLATFORM = {
|
|
128
|
+
"twitter": "twitter",
|
|
129
|
+
"reddit": "reddit",
|
|
130
|
+
"linkedin": "linkedin",
|
|
131
|
+
"github_issues": "github",
|
|
132
|
+
"moltbook": "moltbook",
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _resolve_default_account(platform: str) -> str:
|
|
137
|
+
"""Return the configured account handle for `platform` on this machine.
|
|
138
|
+
|
|
139
|
+
Resolved ONLY from env (`AUTOPOSTER_<PLATFORM>_*`) or config.json
|
|
140
|
+
(`accounts.<platform>.*`) via account_resolver. There are NO hardcoded
|
|
141
|
+
handle fallbacks: a misconfigured install must never silently post under
|
|
142
|
+
another person's identity. The old per-platform defaults
|
|
143
|
+
(m13v_/Deep_Ad1959/Matthew Diakonov/m13v/matthew-autoposter) did exactly
|
|
144
|
+
that, stamping every unconfigured install's rows with the repo owner's
|
|
145
|
+
handle and polluting the shared DB across accounts.
|
|
146
|
+
|
|
147
|
+
Returns "" when nothing is configured; the caller's
|
|
148
|
+
`args.account or _resolve_default_account(...)` chain still lets an
|
|
149
|
+
explicit `--account` flag win, and an empty value surfaces the misconfig
|
|
150
|
+
instead of impersonating someone.
|
|
151
|
+
"""
|
|
152
|
+
try:
|
|
153
|
+
import account_resolver
|
|
154
|
+
return account_resolver.resolve(
|
|
155
|
+
_RESOLVER_PLATFORM.get(platform, platform)
|
|
156
|
+
) or ""
|
|
157
|
+
except Exception:
|
|
158
|
+
return ""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def coerce_engagement_style(args):
|
|
162
|
+
"""Run the picker-coercion engine and return the style to log.
|
|
163
|
+
|
|
164
|
+
Shared by INSERT mode and --rejected mode so a server-rejected attempt
|
|
165
|
+
records the same coerced/assigned style as a successful one (otherwise
|
|
166
|
+
INVENT-mode model names leak onto rejected rows and pollute the per-style
|
|
167
|
+
report). When the caller passed --assigned-style/--assigned-mode (from
|
|
168
|
+
saps_pick_style in run-linkedin.sh), call validate_or_register exactly
|
|
169
|
+
like twitter_post_plan.py::post_one:
|
|
170
|
+
- USE-mode drift coerces back to the assigned name
|
|
171
|
+
- INVENT-mode + well-formed --new-style registers in the registry
|
|
172
|
+
Falls back to the raw --engagement-style on any error / missing module so
|
|
173
|
+
a registry hiccup never blocks the write. Returns the style string (or
|
|
174
|
+
None) to use for this row.
|
|
175
|
+
"""
|
|
176
|
+
raw_style = (args.engagement_style or "").strip() or None
|
|
177
|
+
if validate_or_register is None or not raw_style:
|
|
178
|
+
return raw_style
|
|
179
|
+
if not (args.assigned_style or args.assigned_mode):
|
|
180
|
+
return raw_style
|
|
181
|
+
|
|
182
|
+
new_style_block = None
|
|
183
|
+
if args.new_style:
|
|
184
|
+
try:
|
|
185
|
+
parsed = json.loads(args.new_style)
|
|
186
|
+
if isinstance(parsed, dict):
|
|
187
|
+
new_style_block = parsed
|
|
188
|
+
except json.JSONDecodeError as e:
|
|
189
|
+
print(json.dumps({
|
|
190
|
+
"warning": "NEW_STYLE_PARSE_FAILED",
|
|
191
|
+
"message": f"could not parse --new-style JSON: {e}",
|
|
192
|
+
}), file=sys.stderr)
|
|
193
|
+
|
|
194
|
+
decision = {
|
|
195
|
+
"engagement_style": raw_style,
|
|
196
|
+
**({"new_style": new_style_block} if new_style_block else {}),
|
|
197
|
+
}
|
|
198
|
+
try:
|
|
199
|
+
coerced_style, action = validate_or_register(
|
|
200
|
+
decision,
|
|
201
|
+
source_post={
|
|
202
|
+
"platform": args.platform,
|
|
203
|
+
"post_url": getattr(args, "our_url", None) or args.thread_url,
|
|
204
|
+
"post_id": None,
|
|
205
|
+
"model": None,
|
|
206
|
+
},
|
|
207
|
+
assigned_style=(args.assigned_style or None),
|
|
208
|
+
assigned_mode=(args.assigned_mode or None),
|
|
209
|
+
)
|
|
210
|
+
except Exception as e:
|
|
211
|
+
print(f"[log_post] validate_or_register raised {e!r}; "
|
|
212
|
+
f"falling back to raw style={raw_style!r}", file=sys.stderr)
|
|
213
|
+
return raw_style
|
|
214
|
+
|
|
215
|
+
if action == "coerced" and coerced_style != raw_style:
|
|
216
|
+
print(f"[log_post] engagement_style coerced {raw_style!r} -> "
|
|
217
|
+
f"{coerced_style!r} (assigned={args.assigned_style!r})",
|
|
218
|
+
file=sys.stderr)
|
|
219
|
+
elif action == "registered":
|
|
220
|
+
print(f"[log_post] registered new engagement_style "
|
|
221
|
+
f"{coerced_style!r} into engagement_styles_registry",
|
|
222
|
+
file=sys.stderr)
|
|
223
|
+
# coerced_style is None only on "rejected" (unknown style, no usable
|
|
224
|
+
# new_style). Keep the raw style so the row still logs a non-null style.
|
|
225
|
+
return (coerced_style or raw_style or "").strip() or None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def mark_self_reply(args):
|
|
229
|
+
if args.post_id is None or not args.self_reply_url or args.self_reply_content is None:
|
|
230
|
+
print(json.dumps({
|
|
231
|
+
"error": "MISSING_ARGS",
|
|
232
|
+
"message": "--mark-self-reply requires --post-id, --self-reply-url, --self-reply-content",
|
|
233
|
+
}))
|
|
234
|
+
sys.exit(1)
|
|
235
|
+
if not args.self_reply_url.startswith("http"):
|
|
236
|
+
print(json.dumps({
|
|
237
|
+
"error": "INVALID_URL",
|
|
238
|
+
"message": f"self-reply-url must start with http, got: {args.self_reply_url[:50]}",
|
|
239
|
+
}))
|
|
240
|
+
sys.exit(1)
|
|
241
|
+
|
|
242
|
+
load_env()
|
|
243
|
+
http_api.api_patch(f"/api/v1/posts/{args.post_id}", {
|
|
244
|
+
"self_reply_url": args.self_reply_url,
|
|
245
|
+
"self_reply_content": args.self_reply_content,
|
|
246
|
+
})
|
|
247
|
+
print(json.dumps({"marked": True, "post_id": args.post_id}))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def log_rejected(args):
|
|
251
|
+
"""Record a comment attempt that the platform rejected server-side.
|
|
252
|
+
|
|
253
|
+
Writes status='rejected_by_platform' so dedup blocks retries on the same
|
|
254
|
+
thread, and stashes the rejection reason + network response in
|
|
255
|
+
source_summary for diagnostics.
|
|
256
|
+
"""
|
|
257
|
+
missing = [f for f in ("platform", "thread_url", "our_content", "project")
|
|
258
|
+
if getattr(args, f) is None]
|
|
259
|
+
if missing:
|
|
260
|
+
print(json.dumps({
|
|
261
|
+
"error": "MISSING_ARGS",
|
|
262
|
+
"message": f"--rejected requires: {', '.join('--' + m.replace('_', '-') for m in missing)}",
|
|
263
|
+
}))
|
|
264
|
+
sys.exit(1)
|
|
265
|
+
|
|
266
|
+
account = args.account or _resolve_default_account(args.platform)
|
|
267
|
+
|
|
268
|
+
# Engagement-style enforcement (2026-05-31 LinkedIn alignment): coerce the
|
|
269
|
+
# model's style back to the picker assignment (USE) or register the
|
|
270
|
+
# invention (INVENT) before the INSERT, so server-rejected rows record the
|
|
271
|
+
# same canonical style as successful ones instead of leaking one-off
|
|
272
|
+
# invented names into the per-style report. See coerce_engagement_style().
|
|
273
|
+
args.engagement_style = coerce_engagement_style(args)
|
|
274
|
+
|
|
275
|
+
summary_parts = []
|
|
276
|
+
if args.rejection_reason:
|
|
277
|
+
summary_parts.append(f"REASON: {args.rejection_reason}")
|
|
278
|
+
if args.network_response:
|
|
279
|
+
summary_parts.append(f"NETWORK: {args.network_response}")
|
|
280
|
+
summary = "\n".join(summary_parts) if summary_parts else "rejected_by_platform"
|
|
281
|
+
|
|
282
|
+
load_env()
|
|
283
|
+
claude_session_id = os.environ.get("CLAUDE_SESSION_ID") or None
|
|
284
|
+
|
|
285
|
+
urn_ids = []
|
|
286
|
+
if args.platform == "linkedin":
|
|
287
|
+
urn_ids = parse_urn_ids(args.urns, args.thread_url, args.network_response)
|
|
288
|
+
|
|
289
|
+
body = {
|
|
290
|
+
"platform": args.platform,
|
|
291
|
+
"thread_url": args.thread_url,
|
|
292
|
+
"our_content": args.our_content,
|
|
293
|
+
"project": args.project,
|
|
294
|
+
"status": "rejected_by_platform",
|
|
295
|
+
"thread_author": args.thread_author or "",
|
|
296
|
+
"thread_title": args.thread_title or "",
|
|
297
|
+
"thread_content": args.thread_content or "",
|
|
298
|
+
"our_account": account,
|
|
299
|
+
"source_summary": summary,
|
|
300
|
+
}
|
|
301
|
+
if args.engagement_style:
|
|
302
|
+
body["engagement_style"] = args.engagement_style
|
|
303
|
+
if args.search_topic:
|
|
304
|
+
body["search_topic"] = args.search_topic
|
|
305
|
+
if args.language:
|
|
306
|
+
body["language"] = args.language
|
|
307
|
+
if claude_session_id:
|
|
308
|
+
body["claude_session_id"] = claude_session_id
|
|
309
|
+
if urn_ids:
|
|
310
|
+
body["urns"] = urn_ids
|
|
311
|
+
# autoposter_version: stamped on every write so we can attribute
|
|
312
|
+
# engagement back to the release of the autoposter code that produced
|
|
313
|
+
# this row. None when package.json + env are both missing; API stores
|
|
314
|
+
# NULL in that case (doesn't block the insert).
|
|
315
|
+
autoposter_version = read_autoposter_version()
|
|
316
|
+
if autoposter_version:
|
|
317
|
+
body["autoposter_version"] = autoposter_version
|
|
318
|
+
|
|
319
|
+
resp = http_api.api_post("/api/v1/posts", body, ok_on_conflict=True)
|
|
320
|
+
if resp and _api_error_code(resp) in ("duplicate_thread", "conflict"):
|
|
321
|
+
print(json.dumps({
|
|
322
|
+
"error": "DUPLICATE_THREAD",
|
|
323
|
+
"message": "Already have a row for this thread",
|
|
324
|
+
"existing_post_id": _api_error_detail(resp, "existing_post_id"),
|
|
325
|
+
}))
|
|
326
|
+
return
|
|
327
|
+
# See note in main() about the resp.data.post.id shape.
|
|
328
|
+
data = (resp or {}).get("data") or {}
|
|
329
|
+
post_obj = data.get("post") or (resp or {}).get("post") or {}
|
|
330
|
+
post_id = post_obj.get("id")
|
|
331
|
+
print(json.dumps({"rejected": True, "post_id": post_id, "urns": urn_ids}))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def main():
|
|
335
|
+
parser = argparse.ArgumentParser(description="Log a posted comment to the database")
|
|
336
|
+
parser.add_argument("--mark-self-reply", action="store_true",
|
|
337
|
+
help="UPDATE mode: mark link_edited_at on an existing post. "
|
|
338
|
+
"Requires --post-id, --self-reply-url, --self-reply-content.")
|
|
339
|
+
parser.add_argument("--rejected", action="store_true",
|
|
340
|
+
help="REJECTED mode: record a server-rejected attempt with "
|
|
341
|
+
"status='rejected_by_platform'. Skips our_url validation. "
|
|
342
|
+
"Use when the platform silently swallowed the comment.")
|
|
343
|
+
parser.add_argument("--rejection-reason", default=None,
|
|
344
|
+
help="Brief reason text (e.g. 'TOAST: comment could not be created'). "
|
|
345
|
+
"Goes into source_summary.")
|
|
346
|
+
parser.add_argument("--network-response", default=None,
|
|
347
|
+
help="Captured XHR response from the comment-create endpoint. "
|
|
348
|
+
"Goes into source_summary (truncated to 4000 chars).")
|
|
349
|
+
parser.add_argument("--post-id", type=int, default=None,
|
|
350
|
+
help="posts.id to update (only with --mark-self-reply)")
|
|
351
|
+
parser.add_argument("--self-reply-url", default=None,
|
|
352
|
+
help="URL of the self-reply that carries the project link")
|
|
353
|
+
parser.add_argument("--self-reply-content", default=None,
|
|
354
|
+
help="Text of the self-reply (goes into link_edit_content)")
|
|
355
|
+
parser.add_argument("--platform", choices=VALID_PLATFORMS)
|
|
356
|
+
parser.add_argument("--thread-url")
|
|
357
|
+
parser.add_argument("--our-url",
|
|
358
|
+
help="Permalink to our posted comment (must start with http)")
|
|
359
|
+
parser.add_argument("--our-content")
|
|
360
|
+
parser.add_argument("--project")
|
|
361
|
+
parser.add_argument("--thread-author", default="")
|
|
362
|
+
parser.add_argument("--thread-title", default="")
|
|
363
|
+
parser.add_argument("--thread-content", default="",
|
|
364
|
+
help="Body text of the original thread/post we're "
|
|
365
|
+
"replying to. Stored in posts.thread_content and "
|
|
366
|
+
"surfaced on the public dashboard so visitors see "
|
|
367
|
+
"the conversation context our comment lives in. "
|
|
368
|
+
"Capped at 4000 chars by the API.")
|
|
369
|
+
parser.add_argument("--account", default=None,
|
|
370
|
+
help="Override default account for the platform")
|
|
371
|
+
parser.add_argument("--engagement-style", default=None,
|
|
372
|
+
help="Tone style (e.g. critic, storyteller). Separate from "
|
|
373
|
+
"--is-recommendation, which is intent.")
|
|
374
|
+
parser.add_argument("--assigned-style", default=None,
|
|
375
|
+
help="The engagement style the programmatic picker "
|
|
376
|
+
"(saps_pick_style / pick_style_for_post) assigned for "
|
|
377
|
+
"this post. When present alongside --assigned-mode, "
|
|
378
|
+
"log_post runs validate_or_register so USE-mode drift "
|
|
379
|
+
"is coerced back to this name and INVENT-mode names "
|
|
380
|
+
"register in engagement_styles_registry. Mirrors "
|
|
381
|
+
"Twitter's --assigned-style on log_draft.py. Empty on "
|
|
382
|
+
"INVENT mode (picker assigns no concrete name).")
|
|
383
|
+
parser.add_argument("--assigned-mode", default=None,
|
|
384
|
+
help="Picker mode for this post: 'use' (a concrete style "
|
|
385
|
+
"was assigned, drift coerces back) or 'invent' (model "
|
|
386
|
+
"creates a new snake_case style + --new-style block). "
|
|
387
|
+
"Drives validate_or_register's enforcement branch.")
|
|
388
|
+
parser.add_argument("--new-style", default=None,
|
|
389
|
+
help="JSON object describing a model-invented style, REQUIRED "
|
|
390
|
+
"iff --assigned-mode=invent and --engagement-style is a "
|
|
391
|
+
"new name not in the registry. Shape mirrors "
|
|
392
|
+
"engagement_styles.py::_REQUIRED_NEW_STYLE_FIELDS: "
|
|
393
|
+
"{description, example, why_existing_didnt_fit, "
|
|
394
|
+
"note?}. Passed through to validate_or_register so the "
|
|
395
|
+
"invention lands in engagement_styles_registry.")
|
|
396
|
+
parser.add_argument("--search-topic", default=None,
|
|
397
|
+
help="Topic seed from the project's search_topics list "
|
|
398
|
+
"(or a model-invented variant) that surfaced this "
|
|
399
|
+
"thread. Stamped on posts.search_topic so "
|
|
400
|
+
"top_search_topics.py can aggregate per-topic "
|
|
401
|
+
"conversion. For Twitter this should be copied "
|
|
402
|
+
"from twitter_candidates.search_topic; Reddit and "
|
|
403
|
+
"GitHub already populate this field via their own "
|
|
404
|
+
"log-post wrappers.")
|
|
405
|
+
parser.add_argument("--is-recommendation", action="store_true",
|
|
406
|
+
help="Mark this post as a project mention/recommendation. "
|
|
407
|
+
"Composes with --engagement-style; tone and intent are "
|
|
408
|
+
"independent dimensions.")
|
|
409
|
+
parser.add_argument("--language", default=None,
|
|
410
|
+
help="ISO 639-1 language code (e.g. en, ja, zh, es)")
|
|
411
|
+
parser.add_argument("--link-source", default=None,
|
|
412
|
+
help="How the link in our_content was sourced: "
|
|
413
|
+
"seo_page | plain_url_ab_skip | plain_url_no_lp | "
|
|
414
|
+
"plain_url_fallback:<reason> | empty[_*]. "
|
|
415
|
+
"Used to A/B compare engagement between the "
|
|
416
|
+
"page-gen and plain-URL lanes on Twitter.")
|
|
417
|
+
parser.add_argument("--tail-link-variant", default=None,
|
|
418
|
+
help="Tail-link AB test arm for Twitter posts: "
|
|
419
|
+
"'link' (reply includes bridge sentence + URL) or "
|
|
420
|
+
"'no_link' (reply posted without any link tail). "
|
|
421
|
+
"NULL for non-Twitter posts and rows pre-dating "
|
|
422
|
+
"the experiment. Stored in posts.tail_link_variant.")
|
|
423
|
+
parser.add_argument("--target-chars", type=int, default=None,
|
|
424
|
+
help="Snapshot of the assigned engagement style's "
|
|
425
|
+
"target comment length (chars) at post time. "
|
|
426
|
+
"Frozen onto posts.target_chars so "
|
|
427
|
+
"style_length_report can compare realized-vs-target "
|
|
428
|
+
"length immune to later registry drift. Resolved by "
|
|
429
|
+
"the caller (twitter_post_plan.py) from the final "
|
|
430
|
+
"coerced style via the registry. NULL leaves the "
|
|
431
|
+
"column empty; the report falls back to the live "
|
|
432
|
+
"registry target for NULL rows.")
|
|
433
|
+
parser.add_argument("--length-arm", default=None,
|
|
434
|
+
help="Historical Twitter length-control A/B arm. The live "
|
|
435
|
+
"experiment concluded 2026-06-04 and production no "
|
|
436
|
+
"longer passes this flag; keep it for old rows and "
|
|
437
|
+
"manual/backfill writes to posts.length_arm. Expected "
|
|
438
|
+
"values: 'treatment' or 'control'.")
|
|
439
|
+
parser.add_argument("--draft-prompt-variant", default=None,
|
|
440
|
+
help="Draft-prompt A/B arm for Twitter posts: 'treatment' "
|
|
441
|
+
"(decoupled draft directive; reply stands on its own, "
|
|
442
|
+
"no forced concede->pivot to product) or 'control' "
|
|
443
|
+
"(current directive). Assigned per cycle in "
|
|
444
|
+
"run-twitter-cycle.sh and read from S4L_DRAFT_PROMPT_VARIANT "
|
|
445
|
+
"by twitter_post_plan.py. NULL for non-Twitter rows and "
|
|
446
|
+
"rows pre-dating the experiment. Stored in "
|
|
447
|
+
"posts.draft_prompt_variant.")
|
|
448
|
+
parser.add_argument("--urns", default=None,
|
|
449
|
+
help="LinkedIn-only: comma- or whitespace-separated list "
|
|
450
|
+
"of 16-19 digit URN IDs that identify this post "
|
|
451
|
+
"(activity, ugcPost, share). Pass everything you "
|
|
452
|
+
"captured from the createComment network response. "
|
|
453
|
+
"log_post.py merges these with IDs extracted from "
|
|
454
|
+
"thread_url and our_url before INSERT, so dedup "
|
|
455
|
+
"via posts.urns catches future cross-URN collisions.")
|
|
456
|
+
parser.add_argument("--generation-trace", default=None,
|
|
457
|
+
help="Path to a JSON file with the few-shot context "
|
|
458
|
+
"Claude saw before drafting this post. Stored in "
|
|
459
|
+
"posts.generation_trace JSONB so a later audit "
|
|
460
|
+
"can reconstruct 'which examples produced this "
|
|
461
|
+
"output?'. Pass the file path (NOT inline JSON) "
|
|
462
|
+
"to keep argv short and avoid shell-escape pain. "
|
|
463
|
+
"Capped at 64 KB by the API. See "
|
|
464
|
+
"migrations/2026-05-12_generation_trace.sql.")
|
|
465
|
+
parser.add_argument("--thread-engagement", default=None,
|
|
466
|
+
help="JSON string snapshot of the original thread's "
|
|
467
|
+
"engagement at scrape time. Shape: "
|
|
468
|
+
"{\"likes\":N,\"retweets\":N,\"replies\":N,"
|
|
469
|
+
"\"views\":N,\"bookmarks\":N,\"snapshot_at\":\"...\"}. "
|
|
470
|
+
"Stored verbatim in posts.thread_engagement (TEXT). "
|
|
471
|
+
"No live refresh, no extra API calls; whatever the "
|
|
472
|
+
"candidate row already had under *_t0 is what gets "
|
|
473
|
+
"recorded. Capped at 2 KB by the API.")
|
|
474
|
+
parser.add_argument("--thread-media", default=None,
|
|
475
|
+
help="JSON array snapshot of the original thread's media "
|
|
476
|
+
"([{\"url\":...,\"alt\":...,\"type\":\"image|video|gif|card\"}]) "
|
|
477
|
+
"captured at draft time. Stored in posts.thread_media "
|
|
478
|
+
"(JSONB) as the immutable record of what the thread "
|
|
479
|
+
"visually showed when we replied. An empty array [] is "
|
|
480
|
+
"valid (captured-none). Omitted/None leaves the column "
|
|
481
|
+
"NULL (never captured). 2026-06-03 thread-media feature.")
|
|
482
|
+
args = parser.parse_args()
|
|
483
|
+
|
|
484
|
+
if args.mark_self_reply:
|
|
485
|
+
mark_self_reply(args)
|
|
486
|
+
return
|
|
487
|
+
|
|
488
|
+
if args.rejected:
|
|
489
|
+
log_rejected(args)
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
# INSERT mode — enforce required fields that argparse can't conditionally require.
|
|
493
|
+
missing = [f for f in ("platform", "thread_url", "our_url", "our_content", "project")
|
|
494
|
+
if getattr(args, f) is None]
|
|
495
|
+
if missing:
|
|
496
|
+
print(json.dumps({
|
|
497
|
+
"error": "MISSING_ARGS",
|
|
498
|
+
"message": f"INSERT mode requires: {', '.join('--' + m.replace('_', '-') for m in missing)}",
|
|
499
|
+
}))
|
|
500
|
+
sys.exit(1)
|
|
501
|
+
|
|
502
|
+
# Validate our_url
|
|
503
|
+
if not args.our_url.startswith("http"):
|
|
504
|
+
print(json.dumps({
|
|
505
|
+
"error": "INVALID_URL",
|
|
506
|
+
"message": f"our_url must start with http, got: {args.our_url[:50]}",
|
|
507
|
+
}))
|
|
508
|
+
sys.exit(1)
|
|
509
|
+
|
|
510
|
+
account = args.account or _resolve_default_account(args.platform)
|
|
511
|
+
|
|
512
|
+
# Engagement-style enforcement (2026-05-31 LinkedIn alignment): coerce the
|
|
513
|
+
# model's style back to the picker assignment (USE) or register the
|
|
514
|
+
# invention (INVENT) before the INSERT. See coerce_engagement_style().
|
|
515
|
+
args.engagement_style = coerce_engagement_style(args)
|
|
516
|
+
|
|
517
|
+
# LinkedIn: same post surfaces under multiple URL shapes (/feed/update/
|
|
518
|
+
# vs /posts/...-share-...) with different numeric URNs. Canonicalize
|
|
519
|
+
# our_url to /feed/update/urn:li:activity:<id>/ so the comment-permalink
|
|
520
|
+
# captured after posting drops its commentUrn query string.
|
|
521
|
+
urn_ids = []
|
|
522
|
+
if args.platform == "linkedin":
|
|
523
|
+
# Preserve a ?commentUrn= query (it identifies OUR engagement-comment)
|
|
524
|
+
# across canonicalization. canonicalize() runs ACTIVITY_URN_RE over the
|
|
525
|
+
# whole URL and, when the URL carries
|
|
526
|
+
# ?commentUrn=urn:li:comment:(activity:<parent>,<cid>)
|
|
527
|
+
# it matches the INNER parent activity and collapses the entire URL to
|
|
528
|
+
# /feed/update/urn:li:activity:<parent>/, dropping both the base post
|
|
529
|
+
# URN and our comment id. That breaks the stats matcher
|
|
530
|
+
# (update_linkedin_stats_from_feed.py keys on the numeric comment id
|
|
531
|
+
# inside commentUrn). Fix: canonicalize the PATH-ONLY base, then
|
|
532
|
+
# re-attach the original commentUrn so the stored our_url keeps it.
|
|
533
|
+
_split = urllib.parse.urlsplit(args.our_url or "")
|
|
534
|
+
_qs = urllib.parse.parse_qs(_split.query)
|
|
535
|
+
_comment_urn = (_qs.get("commentUrn") or [None])[0]
|
|
536
|
+
_base = urllib.parse.urlunsplit(
|
|
537
|
+
(_split.scheme, _split.netloc, _split.path, "", "")
|
|
538
|
+
)
|
|
539
|
+
_canon = li_url.canonicalize(_base)
|
|
540
|
+
if _comment_urn:
|
|
541
|
+
_sep = "&" if "?" in _canon else "?"
|
|
542
|
+
args.our_url = (
|
|
543
|
+
_canon + _sep + "commentUrn="
|
|
544
|
+
+ urllib.parse.quote(_comment_urn, safe="")
|
|
545
|
+
)
|
|
546
|
+
else:
|
|
547
|
+
args.our_url = _canon
|
|
548
|
+
# Build the full URN-ID set for this post: --urns input plus
|
|
549
|
+
# everything we can extract from thread_url and our_url. Stored in
|
|
550
|
+
# posts.urns so future dedup queries catch any URN form (activity,
|
|
551
|
+
# ugcPost, share) regardless of which one the candidate-page DOM
|
|
552
|
+
# renders. Without this, the search-page only exposes the ugcPost
|
|
553
|
+
# URN while we stored only the activity URN, so the cross-URN
|
|
554
|
+
# collision check missed and we double-posted.
|
|
555
|
+
urn_ids = parse_urn_ids(args.urns, args.thread_url, args.our_url)
|
|
556
|
+
|
|
557
|
+
load_env()
|
|
558
|
+
claude_session_id = os.environ.get("CLAUDE_SESSION_ID") or None
|
|
559
|
+
|
|
560
|
+
body = {
|
|
561
|
+
"platform": args.platform,
|
|
562
|
+
"thread_url": args.thread_url,
|
|
563
|
+
"our_url": args.our_url,
|
|
564
|
+
"our_content": args.our_content,
|
|
565
|
+
"project": args.project,
|
|
566
|
+
"thread_author": args.thread_author or "",
|
|
567
|
+
"thread_title": args.thread_title or "",
|
|
568
|
+
"thread_content": args.thread_content or "",
|
|
569
|
+
"our_account": account,
|
|
570
|
+
"is_recommendation": bool(args.is_recommendation),
|
|
571
|
+
}
|
|
572
|
+
if args.engagement_style:
|
|
573
|
+
body["engagement_style"] = args.engagement_style
|
|
574
|
+
if args.search_topic:
|
|
575
|
+
body["search_topic"] = args.search_topic
|
|
576
|
+
if args.language:
|
|
577
|
+
body["language"] = args.language
|
|
578
|
+
if claude_session_id:
|
|
579
|
+
body["claude_session_id"] = claude_session_id
|
|
580
|
+
if urn_ids:
|
|
581
|
+
body["urns"] = urn_ids
|
|
582
|
+
if args.link_source:
|
|
583
|
+
body["link_source"] = args.link_source
|
|
584
|
+
if args.tail_link_variant:
|
|
585
|
+
body["tail_link_variant"] = args.tail_link_variant
|
|
586
|
+
if args.draft_prompt_variant:
|
|
587
|
+
body["draft_prompt_variant"] = args.draft_prompt_variant
|
|
588
|
+
if args.target_chars:
|
|
589
|
+
body["target_chars"] = args.target_chars
|
|
590
|
+
if args.length_arm:
|
|
591
|
+
body["length_arm"] = args.length_arm
|
|
592
|
+
if args.thread_engagement:
|
|
593
|
+
body["thread_engagement"] = args.thread_engagement
|
|
594
|
+
# Thread media snapshot (2026-06-03): the media of the thread we replied to,
|
|
595
|
+
# frozen onto posts.thread_media as an immutable audit record. Read from the
|
|
596
|
+
# candidate row by twitter_post_plan.py and forwarded here as a JSON array
|
|
597
|
+
# string. Parse defensively: a malformed value must NOT block the post, so on
|
|
598
|
+
# any parse error we skip the field (column stays NULL) rather than failing.
|
|
599
|
+
if args.thread_media is not None:
|
|
600
|
+
try:
|
|
601
|
+
parsed_media = json.loads(args.thread_media)
|
|
602
|
+
if isinstance(parsed_media, list):
|
|
603
|
+
body["thread_media"] = parsed_media
|
|
604
|
+
except (TypeError, ValueError) as e:
|
|
605
|
+
print(json.dumps({
|
|
606
|
+
"warning": "THREAD_MEDIA_PARSE_FAILED",
|
|
607
|
+
"message": f"could not parse --thread-media: {e}",
|
|
608
|
+
}), file=sys.stderr)
|
|
609
|
+
# autoposter_version: stamped on every write so we can attribute
|
|
610
|
+
# engagement back to the release of the autoposter code that produced
|
|
611
|
+
# this row. None when package.json + env are both missing.
|
|
612
|
+
autoposter_version = read_autoposter_version()
|
|
613
|
+
if autoposter_version:
|
|
614
|
+
body["autoposter_version"] = autoposter_version
|
|
615
|
+
# Generation trace: read the JSON file and pass as-is. We do NOT
|
|
616
|
+
# validate the inner shape here; the API enforces the 64 KB cap and
|
|
617
|
+
# rejects non-object payloads. If the file is missing or unparseable
|
|
618
|
+
# we skip the field silently rather than failing the post — losing
|
|
619
|
+
# the audit row for one post is preferable to losing the post itself.
|
|
620
|
+
if args.generation_trace:
|
|
621
|
+
try:
|
|
622
|
+
with open(args.generation_trace, "r", encoding="utf-8") as tf:
|
|
623
|
+
body["generation_trace"] = json.load(tf)
|
|
624
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
625
|
+
print(json.dumps({
|
|
626
|
+
"warning": "GENERATION_TRACE_LOAD_FAILED",
|
|
627
|
+
"message": f"could not load {args.generation_trace}: {e}",
|
|
628
|
+
}), file=sys.stderr)
|
|
629
|
+
|
|
630
|
+
resp = http_api.api_post("/api/v1/posts", body, ok_on_conflict=True)
|
|
631
|
+
if resp and _api_error_code(resp) in ("duplicate_thread", "conflict"):
|
|
632
|
+
print(json.dumps({
|
|
633
|
+
"error": "DUPLICATE_THREAD",
|
|
634
|
+
"message": "Already posted in this thread",
|
|
635
|
+
"existing_post_id": _api_error_detail(resp, "existing_post_id"),
|
|
636
|
+
"content_preview": _api_error_detail(resp, "content_preview"),
|
|
637
|
+
}))
|
|
638
|
+
return
|
|
639
|
+
# API response shape is {"ok":true,"data":{"post":{"id":N,...}}}.
|
|
640
|
+
# Earlier code looked at resp["post"]["id"] which silently returns None
|
|
641
|
+
# against the current API, causing twitter_post_plan.py to drop into the
|
|
642
|
+
# log_post_no_id branch even when the row was successfully inserted.
|
|
643
|
+
# Accept both shapes for backwards compat.
|
|
644
|
+
data = (resp or {}).get("data") or {}
|
|
645
|
+
post_obj = data.get("post") or (resp or {}).get("post") or {}
|
|
646
|
+
post_id = post_obj.get("id")
|
|
647
|
+
print(json.dumps({"logged": True, "post_id": post_id, "urns": urn_ids}))
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
if __name__ == "__main__":
|
|
651
|
+
main()
|