@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,99 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""One-shot: create recurring accelerator-application reminders in
|
|
3
|
+
matt@mediar.ai's Google Calendar via DWD (same SA as the gmail keepalive),
|
|
4
|
+
requesting a calendar scope. If the scope isn't authorized in the mediar.ai
|
|
5
|
+
Workspace DWD config, this fails with unauthorized_client and we fall back.
|
|
6
|
+
"""
|
|
7
|
+
import json, time, urllib.parse, urllib.request, urllib.error
|
|
8
|
+
import google.auth
|
|
9
|
+
from google.auth.transport.requests import Request
|
|
10
|
+
from googleapiclient.discovery import build
|
|
11
|
+
|
|
12
|
+
SA_EMAIL = "gmail-dwd-impersonator@gmail-api-integration-486018.iam.gserviceaccount.com"
|
|
13
|
+
TARGET_USER = "matt@mediar.ai"
|
|
14
|
+
SCOPE = "https://www.googleapis.com/auth/calendar"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def mint_access_token():
|
|
18
|
+
creds, _ = google.auth.default(
|
|
19
|
+
scopes=["https://www.googleapis.com/auth/cloud-platform"]
|
|
20
|
+
)
|
|
21
|
+
creds.refresh(Request())
|
|
22
|
+
iat = int(time.time()); exp = iat + 3600
|
|
23
|
+
claim = {"iss": SA_EMAIL, "sub": TARGET_USER, "scope": SCOPE,
|
|
24
|
+
"aud": "https://oauth2.googleapis.com/token", "iat": iat, "exp": exp}
|
|
25
|
+
iam = build("iamcredentials", "v1", credentials=creds, cache_discovery=False)
|
|
26
|
+
signed = iam.projects().serviceAccounts().signJwt(
|
|
27
|
+
name=f"projects/-/serviceAccounts/{SA_EMAIL}",
|
|
28
|
+
body={"payload": json.dumps(claim)}).execute()
|
|
29
|
+
body = urllib.parse.urlencode({
|
|
30
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
31
|
+
"assertion": signed["signedJwt"]}).encode()
|
|
32
|
+
req = urllib.request.Request("https://oauth2.googleapis.com/token", data=body,
|
|
33
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"})
|
|
34
|
+
try:
|
|
35
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
36
|
+
return json.loads(resp.read())["access_token"]
|
|
37
|
+
except urllib.error.HTTPError as e:
|
|
38
|
+
raise RuntimeError(f"token exchange {e.code}: {e.read().decode()}") from None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main():
|
|
42
|
+
from google.oauth2.credentials import Credentials
|
|
43
|
+
token = mint_access_token()
|
|
44
|
+
cal = build("calendar", "v3", credentials=Credentials(token=token),
|
|
45
|
+
cache_discovery=False)
|
|
46
|
+
|
|
47
|
+
events = [
|
|
48
|
+
{
|
|
49
|
+
"summary": "Apply to a16z Speedrun (next cohort) - S4L",
|
|
50
|
+
"start": "2026-09-15",
|
|
51
|
+
"rrule": "RRULE:FREQ=MONTHLY;INTERVAL=4",
|
|
52
|
+
"description": (
|
|
53
|
+
"Reapply S4L to a16z Speedrun. Speedrun runs ~3 cohorts/year; "
|
|
54
|
+
"this fires every 4 months so you catch the next deadline.\n\n"
|
|
55
|
+
"Apply: https://speedrun.a16z.com/apply (start with email i@m13v.com)\n"
|
|
56
|
+
"Status / update existing app: https://speedrun.a16z.com/application-login\n\n"
|
|
57
|
+
"Reuse the saved answer set (pitch, traction, funding, founder bio). "
|
|
58
|
+
"Last filled 2026-06-03; still need citizenship, university, years of "
|
|
59
|
+
"experience, last-round date, and the 3 investor emails."
|
|
60
|
+
),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"summary": "Apply to PearX (next batch) - S4L",
|
|
64
|
+
"start": "2026-10-01",
|
|
65
|
+
"rrule": "RRULE:FREQ=MONTHLY;INTERVAL=6",
|
|
66
|
+
"description": (
|
|
67
|
+
"Reapply S4L to PearX. Pear runs 2 batches/year (summer + winter); "
|
|
68
|
+
"this fires every 6 months for the next window.\n\n"
|
|
69
|
+
"Apply: https://pear.vc/pearx-application/ (Airtable form; long-text "
|
|
70
|
+
"fields are contenteditable divs)\n\n"
|
|
71
|
+
"Reuse the saved answer set. PearX S26 app was filled 2026-06-03 "
|
|
72
|
+
"(left for review, not submitted)."
|
|
73
|
+
),
|
|
74
|
+
},
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
created = []
|
|
78
|
+
for ev in events:
|
|
79
|
+
body = {
|
|
80
|
+
"summary": ev["summary"],
|
|
81
|
+
"description": ev["description"],
|
|
82
|
+
"start": {"date": ev["start"]},
|
|
83
|
+
"end": {"date": ev["start"]},
|
|
84
|
+
"recurrence": [ev["rrule"]],
|
|
85
|
+
"reminders": {"useDefault": False, "overrides": [
|
|
86
|
+
{"method": "popup", "minutes": 24 * 60},
|
|
87
|
+
{"method": "email", "minutes": 24 * 60},
|
|
88
|
+
]},
|
|
89
|
+
"transparency": "transparent",
|
|
90
|
+
}
|
|
91
|
+
out = cal.events().insert(calendarId="primary", body=body).execute()
|
|
92
|
+
created.append((ev["summary"], out.get("htmlLink")))
|
|
93
|
+
|
|
94
|
+
for s, link in created:
|
|
95
|
+
print(f"CREATED: {s}\n {link}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
main()
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Single source of truth for the posting account on every platform.
|
|
2
|
+
|
|
3
|
+
Resolution order for each platform (first non-empty wins):
|
|
4
|
+
|
|
5
|
+
1. Env var `AUTOPOSTER_<PLATFORM>_HANDLE` (used by the VM / per-account
|
|
6
|
+
systemd or launchd units to override config.json without rewriting the
|
|
7
|
+
checked-in file). Twitter retains the legacy `AUTOPOSTER_TWITTER_HANDLE`
|
|
8
|
+
name as an alias.
|
|
9
|
+
2. The matching field in `config.json` -> `accounts.<platform>.<field>`.
|
|
10
|
+
|
|
11
|
+
The handle is normalized:
|
|
12
|
+
- leading `@` is stripped (twitter)
|
|
13
|
+
- leading `u/` is stripped (reddit)
|
|
14
|
+
- surrounding whitespace is stripped
|
|
15
|
+
So both `@matt_diak` and `matt_diak` resolve to `matt_diak`, both
|
|
16
|
+
`u/Deep_Ad1959` and `Deep_Ad1959` resolve to `Deep_Ad1959`, matching the
|
|
17
|
+
canonical shape stored in `posts.our_account` after the 2026-05-20 migration.
|
|
18
|
+
|
|
19
|
+
Returns None if neither source has a value. Callers should treat None as
|
|
20
|
+
"unknown account" and decline to scope per-account work that needs a handle
|
|
21
|
+
(e.g. dedupe filters).
|
|
22
|
+
|
|
23
|
+
Platform key map:
|
|
24
|
+
twitter -> accounts.twitter.handle (env: AUTOPOSTER_TWITTER_HANDLE)
|
|
25
|
+
reddit -> accounts.reddit.username (env: AUTOPOSTER_REDDIT_USERNAME)
|
|
26
|
+
linkedin -> accounts.linkedin.name (env: AUTOPOSTER_LINKEDIN_NAME)
|
|
27
|
+
github -> accounts.github.username (env: AUTOPOSTER_GITHUB_USERNAME)
|
|
28
|
+
moltbook -> accounts.moltbook.username (env: AUTOPOSTER_MOLTBOOK_USERNAME)
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
from functools import lru_cache
|
|
35
|
+
from typing import Optional
|
|
36
|
+
|
|
37
|
+
_PLATFORM_CONFIG_FIELD = {
|
|
38
|
+
"twitter": ("twitter", "handle"),
|
|
39
|
+
"x": ("twitter", "handle"), # alias for the canonical post-platform
|
|
40
|
+
"reddit": ("reddit", "username"),
|
|
41
|
+
"linkedin": ("linkedin", "name"),
|
|
42
|
+
"github": ("github", "username"),
|
|
43
|
+
"moltbook": ("moltbook", "username"),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_PLATFORM_ENV_NAME = {
|
|
47
|
+
"twitter": "AUTOPOSTER_TWITTER_HANDLE",
|
|
48
|
+
"x": "AUTOPOSTER_TWITTER_HANDLE",
|
|
49
|
+
"reddit": "AUTOPOSTER_REDDIT_USERNAME",
|
|
50
|
+
"linkedin": "AUTOPOSTER_LINKEDIN_NAME",
|
|
51
|
+
"github": "AUTOPOSTER_GITHUB_USERNAME",
|
|
52
|
+
"moltbook": "AUTOPOSTER_MOLTBOOK_USERNAME",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def normalize(handle: Optional[str]) -> Optional[str]:
|
|
57
|
+
"""Canonicalize a raw account handle.
|
|
58
|
+
|
|
59
|
+
Drops leading `@` (twitter) and `u/` (reddit) plus surrounding
|
|
60
|
+
whitespace. Returns None for empty input.
|
|
61
|
+
"""
|
|
62
|
+
if not handle:
|
|
63
|
+
return None
|
|
64
|
+
h = handle.strip()
|
|
65
|
+
if h.startswith("@"):
|
|
66
|
+
h = h[1:]
|
|
67
|
+
elif h.lower().startswith("u/"):
|
|
68
|
+
h = h[2:]
|
|
69
|
+
h = h.strip()
|
|
70
|
+
return h or None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@lru_cache(maxsize=1)
|
|
74
|
+
def _load_config() -> dict:
|
|
75
|
+
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
76
|
+
cfg_path = os.path.join(repo_root, "config.json")
|
|
77
|
+
try:
|
|
78
|
+
with open(cfg_path, "r", encoding="utf-8") as f:
|
|
79
|
+
return json.load(f) or {}
|
|
80
|
+
except (OSError, json.JSONDecodeError):
|
|
81
|
+
return {}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def resolve(platform: str) -> Optional[str]:
|
|
85
|
+
"""Return the normalized posting handle for `platform`, or None."""
|
|
86
|
+
key = (platform or "").strip().lower()
|
|
87
|
+
if key not in _PLATFORM_CONFIG_FIELD:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
env_name = _PLATFORM_ENV_NAME[key]
|
|
91
|
+
env_value = normalize(os.environ.get(env_name))
|
|
92
|
+
if env_value:
|
|
93
|
+
return env_value
|
|
94
|
+
|
|
95
|
+
section, field = _PLATFORM_CONFIG_FIELD[key]
|
|
96
|
+
cfg = _load_config()
|
|
97
|
+
accounts = cfg.get("accounts") or {}
|
|
98
|
+
block = accounts.get(section) or {}
|
|
99
|
+
return normalize(block.get(field))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def require(platform: str) -> str:
|
|
103
|
+
"""Like resolve() but raises if no handle is configured."""
|
|
104
|
+
h = resolve(platform)
|
|
105
|
+
if not h:
|
|
106
|
+
section, field = _PLATFORM_CONFIG_FIELD.get(
|
|
107
|
+
(platform or "").lower(), ("?", "?")
|
|
108
|
+
)
|
|
109
|
+
env_name = _PLATFORM_ENV_NAME.get(
|
|
110
|
+
(platform or "").lower(), f"AUTOPOSTER_{platform.upper()}_HANDLE"
|
|
111
|
+
)
|
|
112
|
+
raise RuntimeError(
|
|
113
|
+
f"No account configured for platform={platform!r}. "
|
|
114
|
+
f"Set env {env_name} or accounts.{section}.{field} in config.json."
|
|
115
|
+
)
|
|
116
|
+
return h
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Backwards-compatible shim so the existing twitter-only call site keeps
|
|
120
|
+
# working without churn. `from twitter_account import resolve_handle` will
|
|
121
|
+
# continue to work; new code should call `account_resolver.resolve('twitter')`.
|
|
122
|
+
def resolve_handle() -> Optional[str]:
|
|
123
|
+
return resolve("twitter")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def require_handle() -> str:
|
|
127
|
+
return require("twitter")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
import sys
|
|
132
|
+
if len(sys.argv) > 1:
|
|
133
|
+
plat = sys.argv[1]
|
|
134
|
+
else:
|
|
135
|
+
plat = "twitter"
|
|
136
|
+
h = resolve(plat)
|
|
137
|
+
if h:
|
|
138
|
+
sys.stdout.write(h + "\n")
|
|
139
|
+
sys.exit(0)
|
|
140
|
+
sys.stderr.write(f"no handle configured for platform={plat}\n")
|
|
141
|
+
sys.exit(1)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fetch active campaigns for a given platform with budget remaining.
|
|
3
|
+
|
|
4
|
+
A campaign is "active" when:
|
|
5
|
+
- status = 'active'
|
|
6
|
+
- its platforms list includes the requested platform
|
|
7
|
+
- max_posts_total is set AND posts_made < max_posts_total
|
|
8
|
+
|
|
9
|
+
Campaigns without max_posts_total are ignored by this script on purpose.
|
|
10
|
+
Every campaign must declare a lifetime cap to be considered.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
python3 active_campaigns.py --platform reddit # prompt block (stdout)
|
|
14
|
+
python3 active_campaigns.py --platform reddit --json # machine-readable
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_active_via_api(platform):
|
|
26
|
+
from http_api import api_get
|
|
27
|
+
resp = api_get(
|
|
28
|
+
"/api/v1/campaigns",
|
|
29
|
+
query={
|
|
30
|
+
"status": "active",
|
|
31
|
+
"platform": platform,
|
|
32
|
+
"with_budget_remaining": "true",
|
|
33
|
+
"limit": 500,
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
rows = ((resp or {}).get("data") or {}).get("campaigns") or []
|
|
37
|
+
out = []
|
|
38
|
+
for r in rows:
|
|
39
|
+
max_total = r.get("max_posts_total")
|
|
40
|
+
posts_made = r.get("posts_made") or 0
|
|
41
|
+
if max_total is None or posts_made >= max_total:
|
|
42
|
+
continue
|
|
43
|
+
out.append({
|
|
44
|
+
"id": int(r["id"]),
|
|
45
|
+
"name": r.get("name"),
|
|
46
|
+
"prompt": r.get("prompt"),
|
|
47
|
+
"max_posts_total": int(max_total),
|
|
48
|
+
"posts_made": int(posts_made),
|
|
49
|
+
"remaining": int(max_total) - int(posts_made),
|
|
50
|
+
})
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_active_campaigns(platform):
|
|
55
|
+
"""Active campaigns for `platform` with budget remaining.
|
|
56
|
+
|
|
57
|
+
Routes through /api/v1/campaigns (HTTP-only).
|
|
58
|
+
"""
|
|
59
|
+
return _get_active_via_api(platform)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def format_prompt_block(campaigns, repo_dir):
|
|
63
|
+
if not campaigns:
|
|
64
|
+
return ""
|
|
65
|
+
|
|
66
|
+
ids_csv = ",".join(str(c["id"]) for c in campaigns)
|
|
67
|
+
lines = []
|
|
68
|
+
lines.append("## ACTIVE CAMPAIGNS (mandatory for every post this run)")
|
|
69
|
+
lines.append("")
|
|
70
|
+
lines.append("The following campaign instructions override your defaults. Follow them exactly.")
|
|
71
|
+
lines.append("")
|
|
72
|
+
|
|
73
|
+
for c in campaigns:
|
|
74
|
+
lines.append(f"### CAMPAIGN id={c['id']} name={c['name']}")
|
|
75
|
+
lines.append(f"Lifetime budget: {c['remaining']} of {c['max_posts_total']} posts remaining.")
|
|
76
|
+
lines.append("Instruction:")
|
|
77
|
+
lines.append(c["prompt"])
|
|
78
|
+
lines.append("")
|
|
79
|
+
|
|
80
|
+
lines.append("## REQUIRED campaign attribution (do this for EVERY post you create)")
|
|
81
|
+
lines.append("")
|
|
82
|
+
lines.append("1. When inserting the post row, use `INSERT INTO posts (...) VALUES (...) RETURNING id;` to capture NEW_POST_ID.")
|
|
83
|
+
lines.append("2. Immediately after, run this shell command to attach the post to the active campaigns:")
|
|
84
|
+
lines.append("")
|
|
85
|
+
lines.append(f" python3 {repo_dir}/scripts/campaign_bump.py --post-id NEW_POST_ID --campaign-ids {ids_csv}")
|
|
86
|
+
lines.append("")
|
|
87
|
+
lines.append("This is mandatory. If you skip it, the campaign counter does not advance and the campaign will over-post.")
|
|
88
|
+
return "\n".join(lines)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main():
|
|
92
|
+
ap = argparse.ArgumentParser()
|
|
93
|
+
ap.add_argument("--platform", required=True)
|
|
94
|
+
ap.add_argument("--json", action="store_true")
|
|
95
|
+
ap.add_argument("--repo-dir", default=os.path.expanduser("~/social-autoposter"))
|
|
96
|
+
args = ap.parse_args()
|
|
97
|
+
|
|
98
|
+
campaigns = get_active_campaigns(args.platform)
|
|
99
|
+
|
|
100
|
+
if args.json:
|
|
101
|
+
print(json.dumps({
|
|
102
|
+
"platform": args.platform,
|
|
103
|
+
"active_count": len(campaigns),
|
|
104
|
+
"campaign_ids": ",".join(str(c["id"]) for c in campaigns),
|
|
105
|
+
"campaigns": campaigns,
|
|
106
|
+
}))
|
|
107
|
+
else:
|
|
108
|
+
block = format_prompt_block(campaigns, args.repo_dir)
|
|
109
|
+
if block:
|
|
110
|
+
print(block)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
main()
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Who is actually using social-autoposter right now.
|
|
3
|
+
|
|
4
|
+
Reads the `installations` heartbeat table (the only live per-install signal) and
|
|
5
|
+
answers "how many real, external people are active" without the inflation that a
|
|
6
|
+
raw install count carries:
|
|
7
|
+
|
|
8
|
+
* install_id is per identity.json, NOT per machine. A reinstall / reset / each
|
|
9
|
+
ephemeral mk0r E2B sandbox mints a fresh id. So we dedupe by `hardware_uuid`
|
|
10
|
+
(the stable per-machine key) and report MACHINES, not install rows.
|
|
11
|
+
* Our own infra (i@m13v.com operator Mac, the agent@mk0r.com / e2b.local VM
|
|
12
|
+
fleet) is filtered out by default so the roster is real customers. Pass
|
|
13
|
+
--all to include it.
|
|
14
|
+
* Cross-references the `posts` table so you can see the alive-but-not-posting
|
|
15
|
+
gap (the blind spot the Cloud Logging stream exists to explain).
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
python3 scripts/active_users.py # external machines, last 7d
|
|
19
|
+
python3 scripts/active_users.py --days 30 # different window
|
|
20
|
+
python3 scripts/active_users.py --all # include our own infra
|
|
21
|
+
python3 scripts/active_users.py --json # machine-readable
|
|
22
|
+
|
|
23
|
+
Operator-local only: uses the direct-Postgres lane via scripts/db.py (absent in
|
|
24
|
+
the shipped npm package), reading DATABASE_URL from ~/social-autoposter/.env.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
from urllib.parse import unquote
|
|
32
|
+
|
|
33
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
34
|
+
from db import load_env, get_conn # noqa: E402
|
|
35
|
+
|
|
36
|
+
# Our own installs, hidden by default so the roster is real external users.
|
|
37
|
+
INTERNAL_EMAILS = {"i@m13v.com", "agent@mk0r.com", "matt@mediar.ai"}
|
|
38
|
+
INTERNAL_HOSTNAME_SUBSTR = ("e2b.local", "71522") # mk0r E2B sandboxes; MacStadium QA box
|
|
39
|
+
# MacStadium remote QA box (hostname "71522", no git_email). It actively runs the
|
|
40
|
+
# pipeline and posts, so without this it masquerades as our only posting customer.
|
|
41
|
+
INTERNAL_HARDWARE_UUIDS = {"07CB793D-6E32-5EF8-82E2-7CDEABD47FBC"}
|
|
42
|
+
|
|
43
|
+
# Connected X handle resolves only from posts.our_account; drop scaffolding values.
|
|
44
|
+
PLACEHOLDER_HANDLES = {"your-twitter-handle", "your_handle", "your-handle", "none", "null", ""}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_handles(raw):
|
|
48
|
+
out = []
|
|
49
|
+
for h in (raw or "").split(","):
|
|
50
|
+
h = h.strip().lstrip("@")
|
|
51
|
+
if h and h.lower() not in PLACEHOLDER_HANDLES and h not in out:
|
|
52
|
+
out.append(h)
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_internal(emails, hostnames, hardware_uuids):
|
|
57
|
+
if any((e or "").lower() in INTERNAL_EMAILS for e in emails):
|
|
58
|
+
return True
|
|
59
|
+
if any(sub in (h or "") for h in hostnames for sub in INTERNAL_HOSTNAME_SUBSTR):
|
|
60
|
+
return True
|
|
61
|
+
if any((u or "") in INTERNAL_HARDWARE_UUIDS for u in hardware_uuids):
|
|
62
|
+
return True
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def fetch(days):
|
|
67
|
+
# One row per MACHINE (hardware_uuid; fall back to a per-install key when the
|
|
68
|
+
# client never reported a hardware_uuid so those installs aren't all merged).
|
|
69
|
+
# `days` is an argparse int (injection-safe), inlined because the wrapper's
|
|
70
|
+
# SQL translation mangles %s placeholders.
|
|
71
|
+
days = int(days)
|
|
72
|
+
q = f"""
|
|
73
|
+
WITH win AS (
|
|
74
|
+
SELECT *,
|
|
75
|
+
COALESCE(NULLIF(git_email, ''), NULLIF(hardware_uuid, ''),
|
|
76
|
+
'anon:' || install_id::text) AS entity_key
|
|
77
|
+
FROM installations
|
|
78
|
+
WHERE last_seen_at > now() - interval '{days} days'
|
|
79
|
+
),
|
|
80
|
+
posted AS (
|
|
81
|
+
SELECT install_id, count(*) AS n
|
|
82
|
+
FROM posts
|
|
83
|
+
WHERE posted_at > now() - interval '{days} days' AND install_id IS NOT NULL
|
|
84
|
+
GROUP BY install_id
|
|
85
|
+
),
|
|
86
|
+
handles AS (
|
|
87
|
+
-- The connected X handle is NOT in the heartbeat; it only reaches the
|
|
88
|
+
-- central DB via posts.our_account, so it exists ONLY for installs that
|
|
89
|
+
-- ever posted (all-time, not windowed: a handle is identity, not activity).
|
|
90
|
+
SELECT install_id, string_agg(DISTINCT our_account, ',') AS hs
|
|
91
|
+
FROM posts
|
|
92
|
+
WHERE our_account IS NOT NULL AND length(trim(our_account)) > 0
|
|
93
|
+
GROUP BY install_id
|
|
94
|
+
)
|
|
95
|
+
SELECT
|
|
96
|
+
w.entity_key,
|
|
97
|
+
count(DISTINCT w.install_id) AS installs,
|
|
98
|
+
count(DISTINCT w.hardware_uuid) AS machines,
|
|
99
|
+
array_remove(array_agg(DISTINCT w.hardware_uuid), NULL) AS hardware_uuids,
|
|
100
|
+
array_remove(array_agg(DISTINCT NULLIF(w.git_email, '')), NULL) AS emails,
|
|
101
|
+
array_remove(array_agg(DISTINCT w.hostname), NULL) AS hostnames,
|
|
102
|
+
string_agg(DISTINCT h.hs, ',') AS handles_raw,
|
|
103
|
+
max(w.os_version) AS os,
|
|
104
|
+
array_remove(array_agg(DISTINCT
|
|
105
|
+
w.last_country || '/' || COALESCE(w.last_city, '-')), NULL) AS locations,
|
|
106
|
+
max(w.last_seen_at) AS last_seen,
|
|
107
|
+
COALESCE(sum(p.n), 0) AS posts
|
|
108
|
+
FROM win w
|
|
109
|
+
LEFT JOIN posted p ON p.install_id = w.install_id
|
|
110
|
+
LEFT JOIN handles h ON h.install_id = w.install_id
|
|
111
|
+
GROUP BY w.entity_key
|
|
112
|
+
ORDER BY last_seen DESC;
|
|
113
|
+
"""
|
|
114
|
+
conn = get_conn()
|
|
115
|
+
try:
|
|
116
|
+
cur = conn.execute(q)
|
|
117
|
+
cols = [c.name for c in cur.description]
|
|
118
|
+
return [dict(zip(cols, row)) for row in cur.fetchall()]
|
|
119
|
+
finally:
|
|
120
|
+
conn.close()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def person(row):
|
|
124
|
+
if row["emails"]:
|
|
125
|
+
return row["emails"][0]
|
|
126
|
+
if row["hostnames"]:
|
|
127
|
+
return row["hostnames"][0]
|
|
128
|
+
return row["entity_key"][:12]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def loc(row):
|
|
132
|
+
return ", ".join(unquote(x) for x in (row["locations"] or [])) or "?"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def main():
|
|
136
|
+
ap = argparse.ArgumentParser(
|
|
137
|
+
description="Active social-autoposter users, deduped per person (email, else machine).")
|
|
138
|
+
ap.add_argument("--days", type=int, default=7, help="lookback window in days (default 7)")
|
|
139
|
+
ap.add_argument("--all", action="store_true", help="include our own infra (i@m13v / mk0r)")
|
|
140
|
+
ap.add_argument("--json", action="store_true", help="emit JSON")
|
|
141
|
+
args = ap.parse_args()
|
|
142
|
+
|
|
143
|
+
load_env()
|
|
144
|
+
rows = fetch(args.days)
|
|
145
|
+
for r in rows:
|
|
146
|
+
r["internal"] = is_internal(r["emails"], r["hostnames"], r["hardware_uuids"])
|
|
147
|
+
r["handles"] = parse_handles(r.get("handles_raw"))
|
|
148
|
+
|
|
149
|
+
external = [r for r in rows if not r["internal"]]
|
|
150
|
+
internal = [r for r in rows if r["internal"]]
|
|
151
|
+
shown = rows if args.all else external
|
|
152
|
+
|
|
153
|
+
if args.json:
|
|
154
|
+
out = [{
|
|
155
|
+
"person": person(r), "x_handles": r["handles"], "machines": r["machines"],
|
|
156
|
+
"installs": r["installs"], "hostnames": r["hostnames"], "emails": r["emails"],
|
|
157
|
+
"os": r["os"], "location": loc(r), "posts": int(r["posts"]),
|
|
158
|
+
"last_seen": r["last_seen"].isoformat() if r["last_seen"] else None,
|
|
159
|
+
"internal": r["internal"],
|
|
160
|
+
} for r in shown]
|
|
161
|
+
print(json.dumps({
|
|
162
|
+
"window_days": args.days,
|
|
163
|
+
"external_machines": len(external),
|
|
164
|
+
"external_people": len({e for r in external for e in r["emails"]}),
|
|
165
|
+
"internal_machines_hidden": 0 if args.all else len(internal),
|
|
166
|
+
"rows": out,
|
|
167
|
+
}, indent=2))
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
people = len({e for r in external for e in r["emails"]})
|
|
171
|
+
print(f"\nActive in last {args.days}d: {len(external)} external machines "
|
|
172
|
+
f"(~{people} identified people){'' if args.all else f', {len(internal)} internal hidden'}\n")
|
|
173
|
+
hdr = (f"{'PERSON':<30} {'X HANDLE':<16} {'HOST':<22} {'OS':<7} {'LOC':<16} "
|
|
174
|
+
f"{'INST':>4} {'POSTS':>6} LAST SEEN")
|
|
175
|
+
print(hdr)
|
|
176
|
+
print("-" * len(hdr))
|
|
177
|
+
for r in shown:
|
|
178
|
+
tag = " [internal]" if r["internal"] else ""
|
|
179
|
+
host = (r["hostnames"][0] if r["hostnames"] else "?")[:22]
|
|
180
|
+
handle = (", ".join(r["handles"]) or "-")[:16]
|
|
181
|
+
print(f"{person(r)[:30]:<30} {handle:<16} {host:<22} {(r['os'] or '?'):<7} "
|
|
182
|
+
f"{loc(r)[:16]:<16} {r['installs']:>4} {int(r['posts']):>6} "
|
|
183
|
+
f"{r['last_seen']:%Y-%m-%d %H:%M}{tag}")
|
|
184
|
+
posting = sum(1 for r in external if r["posts"] > 0)
|
|
185
|
+
print(f"\n of {len(external)} external machines, {posting} posted in the window, "
|
|
186
|
+
f"{len(external) - posting} are alive-but-not-posting.\n")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
main()
|