@m13v/s4l 1.6.197-rc.10
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 +1336 -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 +513 -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,261 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Refresh engagement stats for Instagram posts via the IG Graph API.
|
|
3
|
+
|
|
4
|
+
For each posts.platform='instagram' row, look up its media_id (matching by
|
|
5
|
+
permalink against /me/media for the account), then call /{media-id}/insights
|
|
6
|
+
to fetch views, reach, likes, comments, saved, shares. Write into the same
|
|
7
|
+
flat columns the rest of the dashboard uses (posts.upvotes/comments_count/
|
|
8
|
+
views) plus engagement_updated_at, and snapshot to post_views_daily for the
|
|
9
|
+
Trends tab.
|
|
10
|
+
|
|
11
|
+
Source-of-truth mapping (per `media_posts.target_account`):
|
|
12
|
+
matt_diak -> IG_USER_ID + IG_LONG_TOKEN OR
|
|
13
|
+
IG_USER_ID_MATTDIAK + IG_LONG_TOKEN_MATTDIAK
|
|
14
|
+
matthewheartful -> IG_USER_ID_MATTHEWHEARTFUL + IG_LONG_TOKEN_MATTHEWHEARTFUL
|
|
15
|
+
omidotme -> IG_USER_ID_OMIDOTME + IG_LONG_TOKEN_OMIDOTME
|
|
16
|
+
|
|
17
|
+
Likes -> posts.upvotes (LinkedIn/Twitter convention).
|
|
18
|
+
Views -> posts.views.
|
|
19
|
+
Comments -> posts.comments_count.
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
python3 scripts/update_instagram_stats.py [--quiet] [--limit N]
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
import urllib.error
|
|
32
|
+
import urllib.parse
|
|
33
|
+
import urllib.request
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
37
|
+
from http_api import api_get, api_patch, api_post
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
IG_ENV_PATH = Path.home() / "instagram-graph-api" / ".env"
|
|
41
|
+
GRAPH = "https://graph.instagram.com/v22.0"
|
|
42
|
+
SA_CONFIG = Path(__file__).resolve().parent.parent / "config.json"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ── env / credentials ─────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
def load_ig_env():
|
|
48
|
+
env = {}
|
|
49
|
+
for line in IG_ENV_PATH.read_text().splitlines():
|
|
50
|
+
line = line.strip()
|
|
51
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
52
|
+
continue
|
|
53
|
+
k, v = line.split("=", 1)
|
|
54
|
+
env[k.strip()] = v.strip()
|
|
55
|
+
return env
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve_account_creds(account_name, ig_env, accounts_cfg):
|
|
59
|
+
match = next(
|
|
60
|
+
(a for a in accounts_cfg if a.get("username", "").lower() == account_name.lower()),
|
|
61
|
+
None,
|
|
62
|
+
)
|
|
63
|
+
if match:
|
|
64
|
+
uid = ig_env.get(match.get("ig_user_id_env", "IG_USER_ID"))
|
|
65
|
+
tok = ig_env.get(match.get("ig_long_token_env", "IG_LONG_TOKEN"))
|
|
66
|
+
if uid and tok:
|
|
67
|
+
return uid, tok
|
|
68
|
+
# Legacy bare-env fallback (matt_diak historically used IG_USER_ID/IG_LONG_TOKEN).
|
|
69
|
+
uid = ig_env.get("IG_USER_ID")
|
|
70
|
+
tok = ig_env.get("IG_LONG_TOKEN")
|
|
71
|
+
return uid, tok
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── Graph API helpers ─────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
def graph_get(path, token, **params):
|
|
77
|
+
params["access_token"] = token
|
|
78
|
+
url = f"{GRAPH}/{path}?{urllib.parse.urlencode(params)}"
|
|
79
|
+
with urllib.request.urlopen(url, timeout=20) as r:
|
|
80
|
+
return json.loads(r.read())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def shortcode_from_url(url):
|
|
84
|
+
"""Pull the IG shortcode out of a permalink.
|
|
85
|
+
https://www.instagram.com/reel/DYkkj8RDo9P/ -> DYkkj8RDo9P
|
|
86
|
+
"""
|
|
87
|
+
m = re.search(r"/(?:reel|p|tv)/([A-Za-z0-9_-]+)", url or "")
|
|
88
|
+
return m.group(1) if m else None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def fetch_media_map(ig_user_id, token, max_pages=10):
|
|
92
|
+
"""Return {shortcode: {id, media_product_type, like_count, comments_count}}
|
|
93
|
+
for the account's recent media. Pages through /me/media until exhaustion or
|
|
94
|
+
max_pages safety cap.
|
|
95
|
+
"""
|
|
96
|
+
out = {}
|
|
97
|
+
fields = "id,media_type,media_product_type,permalink,like_count,comments_count"
|
|
98
|
+
url = f"{GRAPH}/{ig_user_id}/media?fields={fields}&limit=100&access_token={token}"
|
|
99
|
+
pages = 0
|
|
100
|
+
while url and pages < max_pages:
|
|
101
|
+
with urllib.request.urlopen(url, timeout=20) as r:
|
|
102
|
+
data = json.loads(r.read())
|
|
103
|
+
for item in data.get("data", []):
|
|
104
|
+
code = shortcode_from_url(item.get("permalink"))
|
|
105
|
+
if code:
|
|
106
|
+
out[code] = item
|
|
107
|
+
url = (data.get("paging") or {}).get("next")
|
|
108
|
+
pages += 1
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def fetch_insights(media_id, mtype, token):
|
|
113
|
+
"""Return {metric_name: value}. Pick metrics based on media type."""
|
|
114
|
+
if mtype == "REELS":
|
|
115
|
+
metrics = "views,reach,likes,comments,saved,shares,total_interactions"
|
|
116
|
+
elif mtype == "VIDEO":
|
|
117
|
+
metrics = "views,reach,likes,comments,saved,shares"
|
|
118
|
+
else:
|
|
119
|
+
metrics = "reach,likes,comments,saved,shares"
|
|
120
|
+
try:
|
|
121
|
+
data = graph_get(f"{media_id}/insights", token, metric=metrics)
|
|
122
|
+
except urllib.error.HTTPError as e:
|
|
123
|
+
return {"__error__": f"HTTP {e.code}: {e.read().decode()[:200]}"}
|
|
124
|
+
result = {}
|
|
125
|
+
for m in data.get("data", []):
|
|
126
|
+
name = m.get("name")
|
|
127
|
+
vals = m.get("values") or []
|
|
128
|
+
result[name] = (vals[0] or {}).get("value") if vals else None
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── main ──────────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
def log(msg, quiet=False):
|
|
135
|
+
if not quiet:
|
|
136
|
+
print(msg)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def main():
|
|
140
|
+
parser = argparse.ArgumentParser()
|
|
141
|
+
parser.add_argument("--quiet", action="store_true")
|
|
142
|
+
parser.add_argument("--limit", type=int, default=None)
|
|
143
|
+
args = parser.parse_args()
|
|
144
|
+
|
|
145
|
+
ig_env = load_ig_env()
|
|
146
|
+
try:
|
|
147
|
+
cfg = json.loads(SA_CONFIG.read_text())
|
|
148
|
+
except FileNotFoundError:
|
|
149
|
+
cfg = {}
|
|
150
|
+
accounts_cfg = ((cfg.get("instagram") or {}).get("accounts") or [])
|
|
151
|
+
|
|
152
|
+
resp = api_get(
|
|
153
|
+
"/api/v1/posts",
|
|
154
|
+
query={
|
|
155
|
+
"platform": "instagram",
|
|
156
|
+
"status": "active",
|
|
157
|
+
"has_our_url": "true",
|
|
158
|
+
"order_by": "id",
|
|
159
|
+
"order_dir": "asc",
|
|
160
|
+
"limit": 500,
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
rows = (resp.get("data") or {}).get("posts") or []
|
|
164
|
+
if args.limit:
|
|
165
|
+
rows = rows[: args.limit]
|
|
166
|
+
|
|
167
|
+
log(f"[stats-ig] {len(rows)} active IG rows to check", args.quiet)
|
|
168
|
+
|
|
169
|
+
by_account = {}
|
|
170
|
+
for r in rows:
|
|
171
|
+
by_account.setdefault(r["our_account"], []).append(r)
|
|
172
|
+
|
|
173
|
+
checked = 0
|
|
174
|
+
updated = 0
|
|
175
|
+
failed = 0
|
|
176
|
+
not_found = 0
|
|
177
|
+
views_refreshed = 0
|
|
178
|
+
|
|
179
|
+
for account, account_rows in by_account.items():
|
|
180
|
+
uid, tok = resolve_account_creds(account, ig_env, accounts_cfg)
|
|
181
|
+
if not uid or not tok:
|
|
182
|
+
log(f"[stats-ig] missing creds for account={account}; skipping {len(account_rows)} rows", args.quiet)
|
|
183
|
+
failed += len(account_rows)
|
|
184
|
+
continue
|
|
185
|
+
try:
|
|
186
|
+
media_map = fetch_media_map(uid, tok)
|
|
187
|
+
except Exception as e:
|
|
188
|
+
log(f"[stats-ig] media list failed for {account}: {e}; skipping {len(account_rows)} rows", args.quiet)
|
|
189
|
+
failed += len(account_rows)
|
|
190
|
+
continue
|
|
191
|
+
log(f"[stats-ig] account={account} media-map size={len(media_map)}", args.quiet)
|
|
192
|
+
|
|
193
|
+
for r in account_rows:
|
|
194
|
+
checked += 1
|
|
195
|
+
code = shortcode_from_url(r["our_url"])
|
|
196
|
+
if not code:
|
|
197
|
+
log(f"[stats-ig] id={r['id']} no shortcode in {r['our_url']}", args.quiet)
|
|
198
|
+
not_found += 1
|
|
199
|
+
continue
|
|
200
|
+
item = media_map.get(code)
|
|
201
|
+
if not item:
|
|
202
|
+
log(f"[stats-ig] id={r['id']} shortcode={code} not in /me/media listing", args.quiet)
|
|
203
|
+
not_found += 1
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
media_id = item["id"]
|
|
207
|
+
mtype = item.get("media_product_type") or item.get("media_type")
|
|
208
|
+
ins = fetch_insights(media_id, mtype, tok)
|
|
209
|
+
if "__error__" in ins:
|
|
210
|
+
log(f"[stats-ig] id={r['id']} insights error: {ins['__error__']}", args.quiet)
|
|
211
|
+
failed += 1
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
likes = ins.get("likes") or item.get("like_count") or 0
|
|
215
|
+
comments = ins.get("comments") or item.get("comments_count") or 0
|
|
216
|
+
views = ins.get("views") # None for photos; that's fine
|
|
217
|
+
|
|
218
|
+
old = (r["upvotes"] or 0, r["comments_count"] or 0, r["views"] or 0)
|
|
219
|
+
new = (likes, comments, views or 0)
|
|
220
|
+
|
|
221
|
+
api_patch(
|
|
222
|
+
f"/api/v1/posts/{r['id']}",
|
|
223
|
+
{
|
|
224
|
+
"upvotes": likes,
|
|
225
|
+
"comments_count": comments,
|
|
226
|
+
"views": views,
|
|
227
|
+
"stamp_engagement_now": True,
|
|
228
|
+
"stamp_status_checked_now": True,
|
|
229
|
+
"reset_deletion_detect_count": True,
|
|
230
|
+
},
|
|
231
|
+
)
|
|
232
|
+
if views is not None:
|
|
233
|
+
api_post(
|
|
234
|
+
"/api/v1/post-views-daily/snapshot",
|
|
235
|
+
{"post_id": r["id"], "views": views},
|
|
236
|
+
)
|
|
237
|
+
views_refreshed += 1
|
|
238
|
+
if new != old:
|
|
239
|
+
updated += 1
|
|
240
|
+
log(
|
|
241
|
+
f"[stats-ig] id={r['id']} code={code} type={mtype} "
|
|
242
|
+
f"likes={likes} comments={comments} views={views}",
|
|
243
|
+
args.quiet,
|
|
244
|
+
)
|
|
245
|
+
# Be polite to the Graph API.
|
|
246
|
+
time.sleep(0.2)
|
|
247
|
+
|
|
248
|
+
log(
|
|
249
|
+
f"[stats-ig] done: checked={checked} updated={updated} "
|
|
250
|
+
f"not_found={not_found} failed={failed} views_refreshed={views_refreshed}",
|
|
251
|
+
args.quiet,
|
|
252
|
+
)
|
|
253
|
+
# Machine-readable summary for the shell wrapper to consume.
|
|
254
|
+
print(
|
|
255
|
+
f"SUMMARY:CHECKED={checked} UPDATED={updated} NOT_FOUND={not_found} "
|
|
256
|
+
f"FAILED={failed} VIEWS_REFRESHED={views_refreshed}"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
if __name__ == "__main__":
|
|
261
|
+
main()
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Update LinkedIn engagement stats for OUR engagement-comments stored in `posts`.
|
|
3
|
+
|
|
4
|
+
This is the posts-table sibling of update_linkedin_comment_stats_from_feed.py.
|
|
5
|
+
Same feed JSON shape (produced by skill/stats-linkedin.sh via
|
|
6
|
+
scrape_linkedin_comment_stats.py); different DB target.
|
|
7
|
+
|
|
8
|
+
Why this exists separately from the replies-table updater:
|
|
9
|
+
LinkedIn engagement-comments are stored in the `posts` table (Twitter
|
|
10
|
+
parity: posts table holds top-level + reply rows alike, identified by
|
|
11
|
+
the URL extracted from `our_url`). The legacy `replies` table holds an
|
|
12
|
+
older sliver (~173 rows) from a previous pipeline shape and is updated
|
|
13
|
+
by update_linkedin_comment_stats_from_feed.py. New rows land in `posts`.
|
|
14
|
+
|
|
15
|
+
Input JSON shape (one record per OUR comment visible on the activity tab,
|
|
16
|
+
virtualized list, partial coverage per fire is expected; identical to the
|
|
17
|
+
shape the replies-table updater consumes):
|
|
18
|
+
[
|
|
19
|
+
{
|
|
20
|
+
"comment_id": "7457492815716032512",
|
|
21
|
+
"parent_kind": "ugcPost" | "activity" | "share",
|
|
22
|
+
"parent_id": "7457485938131161088",
|
|
23
|
+
"impressions": 156,
|
|
24
|
+
"reactions": 7,
|
|
25
|
+
"replies": 1
|
|
26
|
+
},
|
|
27
|
+
...
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
Matching strategy:
|
|
31
|
+
- Posts written 2026-05-11 onward have `our_url` containing
|
|
32
|
+
`?commentUrn=urn:li:comment:(...,<comment_id>)` because
|
|
33
|
+
linkedin_api.py:comment_on_post now embeds it (and reply_to_comment
|
|
34
|
+
already did). We parse comment_id out of `our_url` and key on it.
|
|
35
|
+
- Older rows (pre-2026-05-11) where the autoposter set
|
|
36
|
+
`our_url = thread_url` (parent post URL only, no commentUrn) cannot
|
|
37
|
+
be matched here and will silently miss. They are not backfillable
|
|
38
|
+
without per-permalink scraping (the exact pattern that triggered
|
|
39
|
+
the 2026-04-17 + 2026-05-05 LinkedIn lockouts), so we accept the
|
|
40
|
+
loss. Going forward every new engagement-comment is captured.
|
|
41
|
+
- The 97 pre-existing rows that already have `?commentUrn=` in
|
|
42
|
+
`our_url` (replies-to-comments via reply_to_comment) work
|
|
43
|
+
immediately.
|
|
44
|
+
|
|
45
|
+
Behavior:
|
|
46
|
+
- Match each feed record by comment_id against posts.our_url's
|
|
47
|
+
`commentUrn=` second-numeric-id field.
|
|
48
|
+
- If matched: write upvotes (=reactions), comments_count (=replies),
|
|
49
|
+
views (=impressions), engagement_updated_at = NOW(). Only overwrite
|
|
50
|
+
a column when the new value is non-null.
|
|
51
|
+
- Unmatched feed rows are logged but NOT errors (the same feed JSON
|
|
52
|
+
is consumed by the replies-table updater immediately after this
|
|
53
|
+
script, so a row unmatched here might match there).
|
|
54
|
+
- scan_no_change_count IS maintained, matching stats.py's
|
|
55
|
+
Twitter behavior so dashboard sorting / freshness gates work the
|
|
56
|
+
same way across platforms.
|
|
57
|
+
|
|
58
|
+
Output (stdout) one line for stats.sh's extract_field to parse:
|
|
59
|
+
LinkedInPosts: <T> total, <S> skipped, <C> checked,
|
|
60
|
+
<U> updated, <D> deleted, <E> errors
|
|
61
|
+
|
|
62
|
+
Usage:
|
|
63
|
+
python3 scripts/update_linkedin_stats_from_feed.py \\
|
|
64
|
+
--from-json /tmp/li-stats-feed.json \\
|
|
65
|
+
[--summary /tmp/li-stats-summary.json] \\
|
|
66
|
+
[--dry-run] [--quiet]
|
|
67
|
+
"""
|
|
68
|
+
from __future__ import annotations
|
|
69
|
+
|
|
70
|
+
import argparse
|
|
71
|
+
import json
|
|
72
|
+
import os
|
|
73
|
+
import re
|
|
74
|
+
import sys
|
|
75
|
+
import urllib.parse
|
|
76
|
+
from typing import Optional
|
|
77
|
+
|
|
78
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
79
|
+
from http_api import api_get, api_post # noqa: E402
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# `urn:li:comment:(urn:li:activity:<parent>,<comment_id>)`
|
|
83
|
+
# `urn:li:comment:(urn:li:ugcPost:<parent>,<comment_id>)`
|
|
84
|
+
# `urn:li:comment:(activity:<parent>,<comment_id>)` ← bare-kind form
|
|
85
|
+
# `urn:li:comment:(ugcPost:<parent>,<comment_id>)` ← bare-kind form
|
|
86
|
+
#
|
|
87
|
+
# Same lenient regex as update_linkedin_comment_stats_from_feed.py — the
|
|
88
|
+
# inner `urn:li:` prefix on the parent namespace is optional because both
|
|
89
|
+
# forms appear in our data depending on which posting path wrote the row.
|
|
90
|
+
COMMENT_URN_RE = re.compile(
|
|
91
|
+
r"urn:li:comment:\((?:urn:li:)?(?P<kind>\w+):(?P<parent>\d+),(?P<cid>\d+)\)"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def extract_comment_id(our_url: Optional[str]) -> Optional[tuple[str, str, str]]:
|
|
96
|
+
"""Return (parent_kind, parent_id, comment_id) parsed from our_url, or None."""
|
|
97
|
+
if not our_url:
|
|
98
|
+
return None
|
|
99
|
+
decoded = urllib.parse.unquote(our_url)
|
|
100
|
+
m = COMMENT_URN_RE.search(decoded)
|
|
101
|
+
if not m:
|
|
102
|
+
return None
|
|
103
|
+
return (m.group("kind"), m.group("parent"), m.group("cid"))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_feed(path: str) -> list[dict]:
|
|
107
|
+
with open(path) as f:
|
|
108
|
+
raw = json.load(f)
|
|
109
|
+
if not isinstance(raw, list):
|
|
110
|
+
raise ValueError(f"feed file must be a JSON array, got {type(raw).__name__}")
|
|
111
|
+
out = []
|
|
112
|
+
for r in raw:
|
|
113
|
+
if not isinstance(r, dict):
|
|
114
|
+
continue
|
|
115
|
+
cid = r.get("comment_id")
|
|
116
|
+
if not cid:
|
|
117
|
+
continue
|
|
118
|
+
out.append({
|
|
119
|
+
"comment_id": str(cid),
|
|
120
|
+
"parent_kind": r.get("parent_kind") or "",
|
|
121
|
+
"parent_id": str(r.get("parent_id") or ""),
|
|
122
|
+
"impressions": r.get("impressions"),
|
|
123
|
+
"reactions": r.get("reactions"),
|
|
124
|
+
"replies": r.get("replies"),
|
|
125
|
+
})
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_engagement_comments() -> dict:
|
|
130
|
+
"""Return {comment_id: {id, our_url, upvotes, comments_count, views}}.
|
|
131
|
+
|
|
132
|
+
Only includes LinkedIn `posts` rows where `our_url` carries a
|
|
133
|
+
commentUrn (i.e., we can identify OUR comment, not just the parent
|
|
134
|
+
thread). Status='active' OR 'removed' (removed rows still benefit
|
|
135
|
+
from a final-stats read in case they come back).
|
|
136
|
+
|
|
137
|
+
Migrated 2026-06-01 to GET /api/v1/linkedin-engagement-comments. The
|
|
138
|
+
server returns every candidate row; the brittle commentUrn regex
|
|
139
|
+
(extract_comment_id) stays single-sourced here in Python so the API
|
|
140
|
+
surface never has to replicate it.
|
|
141
|
+
"""
|
|
142
|
+
resp = api_get("/api/v1/linkedin-engagement-comments")
|
|
143
|
+
rows = (resp.get("data") or {}).get("rows") or []
|
|
144
|
+
out = {}
|
|
145
|
+
for r in rows:
|
|
146
|
+
parsed = extract_comment_id(r.get("our_url"))
|
|
147
|
+
if not parsed:
|
|
148
|
+
continue
|
|
149
|
+
_, _, cid = parsed
|
|
150
|
+
out[cid] = {
|
|
151
|
+
"id": r["id"],
|
|
152
|
+
"our_url": r["our_url"],
|
|
153
|
+
"upvotes": int(r.get("upvotes") or 0),
|
|
154
|
+
"comments_count": int(r.get("comments_count") or 0),
|
|
155
|
+
"views": int(r.get("views") or 0),
|
|
156
|
+
}
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def compute_one(db_row: dict, feed: dict, dry_run: bool, quiet: bool) -> dict:
|
|
161
|
+
"""Compute the update for one feed record against one DB row.
|
|
162
|
+
|
|
163
|
+
Returns {post_id, upvotes, comments_count, views, changed}. Only
|
|
164
|
+
overwrites a column when the feed value is non-null (preserves last
|
|
165
|
+
known value for fresh comments that don't yet have impressions
|
|
166
|
+
computed by LinkedIn). The actual write (UPDATE posts +
|
|
167
|
+
scan_no_change_count maintenance + post_views_daily snapshot) happens
|
|
168
|
+
server-side when the batch is POSTed.
|
|
169
|
+
"""
|
|
170
|
+
new_rxn = feed["reactions"]
|
|
171
|
+
new_imp = feed["impressions"]
|
|
172
|
+
new_rep = feed["replies"]
|
|
173
|
+
|
|
174
|
+
next_upv = db_row["upvotes"] if new_rxn is None else int(new_rxn)
|
|
175
|
+
next_cmt = db_row["comments_count"] if new_rep is None else int(new_rep)
|
|
176
|
+
next_vws = db_row["views"] if new_imp is None else int(new_imp)
|
|
177
|
+
|
|
178
|
+
changed = (
|
|
179
|
+
next_upv != db_row["upvotes"]
|
|
180
|
+
or next_cmt != db_row["comments_count"]
|
|
181
|
+
or next_vws != db_row["views"]
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if not quiet:
|
|
185
|
+
tag = "UPDATED" if changed else "same"
|
|
186
|
+
if dry_run:
|
|
187
|
+
tag = f"DRY-{tag}"
|
|
188
|
+
print(
|
|
189
|
+
f" [{db_row['id']:>6}] cid={feed['comment_id']:>20s} "
|
|
190
|
+
f"upv {db_row['upvotes']}->{next_upv} "
|
|
191
|
+
f"cmt {db_row['comments_count']}->{next_cmt} "
|
|
192
|
+
f"views {db_row['views']}->{next_vws} [{tag}]",
|
|
193
|
+
flush=True,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
"post_id": db_row["id"],
|
|
198
|
+
"upvotes": next_upv,
|
|
199
|
+
"comments_count": next_cmt,
|
|
200
|
+
"views": next_vws,
|
|
201
|
+
"changed": changed,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def run(from_json: str,
|
|
206
|
+
summary_path: Optional[str],
|
|
207
|
+
dry_run: bool,
|
|
208
|
+
quiet: bool) -> dict:
|
|
209
|
+
feed = load_feed(from_json)
|
|
210
|
+
if not feed:
|
|
211
|
+
return {
|
|
212
|
+
"ok": True,
|
|
213
|
+
"total": 0, "skipped": 0, "checked": 0,
|
|
214
|
+
"updated": 0, "deleted": 0, "errors": 0,
|
|
215
|
+
"note": "empty_feed",
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
posts_by_cid = load_engagement_comments()
|
|
219
|
+
if not quiet:
|
|
220
|
+
print(
|
|
221
|
+
f"[stats] feed_rows={len(feed)} db_posts_w_commentUrn={len(posts_by_cid)}",
|
|
222
|
+
flush=True,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
updates = []
|
|
226
|
+
unmatched = []
|
|
227
|
+
errors = 0
|
|
228
|
+
|
|
229
|
+
for fr in feed:
|
|
230
|
+
row = posts_by_cid.get(fr["comment_id"])
|
|
231
|
+
if row is None:
|
|
232
|
+
unmatched.append(fr["comment_id"])
|
|
233
|
+
continue
|
|
234
|
+
try:
|
|
235
|
+
updates.append(compute_one(row, fr, dry_run=dry_run, quiet=quiet))
|
|
236
|
+
except Exception as e:
|
|
237
|
+
errors += 1
|
|
238
|
+
if not quiet:
|
|
239
|
+
print(f" ERROR id={row['id']} {e}", flush=True)
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
# Counts come from the locally-computed `changed` flags so the totals
|
|
243
|
+
# match in both dry-run and live mode. The server applies the writes
|
|
244
|
+
# (UPDATE posts + scan_no_change_count + post_views_daily snapshot) and
|
|
245
|
+
# returns its own {updated, unchanged}, which equal these.
|
|
246
|
+
updated = sum(1 for u in updates if u["changed"])
|
|
247
|
+
unchanged = sum(1 for u in updates if not u["changed"])
|
|
248
|
+
|
|
249
|
+
if not dry_run and updates:
|
|
250
|
+
resp = api_post("/api/v1/linkedin-engagement-comments", {"updates": updates})
|
|
251
|
+
srv = resp.get("data") or {}
|
|
252
|
+
# Trust the server tallies when present; they should match locals.
|
|
253
|
+
updated = int(srv.get("updated", updated))
|
|
254
|
+
unchanged = int(srv.get("unchanged", unchanged))
|
|
255
|
+
|
|
256
|
+
total = len(feed)
|
|
257
|
+
checked = updated + unchanged
|
|
258
|
+
skipped = len(unmatched)
|
|
259
|
+
deleted = 0
|
|
260
|
+
|
|
261
|
+
result = {
|
|
262
|
+
"ok": True,
|
|
263
|
+
"total": total,
|
|
264
|
+
"skipped": skipped,
|
|
265
|
+
"checked": checked,
|
|
266
|
+
"updated": updated,
|
|
267
|
+
"unchanged": unchanged,
|
|
268
|
+
"deleted": deleted,
|
|
269
|
+
"errors": errors,
|
|
270
|
+
"unmatched": unmatched,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if summary_path:
|
|
274
|
+
try:
|
|
275
|
+
with open(summary_path, "w") as f:
|
|
276
|
+
json.dump({
|
|
277
|
+
"refreshed": updated,
|
|
278
|
+
"removed": deleted,
|
|
279
|
+
"unavailable": 0,
|
|
280
|
+
"not_found": len(unmatched),
|
|
281
|
+
}, f)
|
|
282
|
+
except Exception as e:
|
|
283
|
+
print(
|
|
284
|
+
f"WARN: failed to write summary {summary_path}: {e}",
|
|
285
|
+
file=sys.stderr,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
return result
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def main() -> None:
|
|
292
|
+
p = argparse.ArgumentParser(
|
|
293
|
+
description=(
|
|
294
|
+
"Apply LinkedIn engagement-comment readings to the posts table."
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
p.add_argument("--from-json", required=True,
|
|
298
|
+
help="Path to JSON produced by scrape_linkedin_comment_stats.py.")
|
|
299
|
+
p.add_argument("--summary", default=None,
|
|
300
|
+
help="Path to write {refreshed,removed,unavailable,not_found} sidecar.")
|
|
301
|
+
p.add_argument("--dry-run", action="store_true",
|
|
302
|
+
help="Compute updates but do not write to DB.")
|
|
303
|
+
p.add_argument("--quiet", action="store_true", help="Minimal output.")
|
|
304
|
+
args = p.parse_args()
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
result = run(args.from_json, args.summary, args.dry_run, args.quiet)
|
|
308
|
+
except Exception as e:
|
|
309
|
+
print(json.dumps({"ok": False, "error": "fatal", "detail": str(e)}),
|
|
310
|
+
file=sys.stderr)
|
|
311
|
+
sys.exit(1)
|
|
312
|
+
|
|
313
|
+
if not result.get("ok"):
|
|
314
|
+
print(json.dumps(result, indent=2), file=sys.stderr)
|
|
315
|
+
sys.exit(1)
|
|
316
|
+
|
|
317
|
+
print(
|
|
318
|
+
f"LinkedInPosts: {result['total']} total, "
|
|
319
|
+
f"{result['skipped']} skipped, "
|
|
320
|
+
f"{result['checked']} checked, "
|
|
321
|
+
f"{result['updated']} updated, "
|
|
322
|
+
f"{result['deleted']} deleted, "
|
|
323
|
+
f"{result['errors']} errors"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
main()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Read the social-autoposter package.json version once and cache it.
|
|
2
|
+
|
|
3
|
+
Every write to posts / replies / dms stamps `autoposter_version` so we can
|
|
4
|
+
attribute engagement back to the release of the autoposter code that
|
|
5
|
+
produced it ("did 1.5.0 outperform 1.4.x on Reddit?").
|
|
6
|
+
|
|
7
|
+
The value comes from:
|
|
8
|
+
1. AUTOPOSTER_VERSION env var, if set (lets us pin during testing or
|
|
9
|
+
override for a one-off backfill).
|
|
10
|
+
2. package.json `version` field in the repo root.
|
|
11
|
+
|
|
12
|
+
Returns None when both lookups fail. Callers MUST tolerate None and pass
|
|
13
|
+
it through to the API; the API stores NULL and the column stays empty for
|
|
14
|
+
that row rather than blocking the write.
|
|
15
|
+
|
|
16
|
+
Why not git SHA: the auto-commit agent at ~/git-dashboard/auto_commit.py
|
|
17
|
+
fires every minute, so the SHA changes constantly without release intent
|
|
18
|
+
and would be noise. The version string is manually bumped per meaningful
|
|
19
|
+
release (bin/cli.js + package.json), which is the right granularity for
|
|
20
|
+
"did this prompt change improve engagement?" analyses.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
29
|
+
_PKG_PATH = os.path.join(_REPO_ROOT, "package.json")
|
|
30
|
+
|
|
31
|
+
_cached: Optional[str] = None
|
|
32
|
+
_cached_loaded = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def read_version() -> Optional[str]:
|
|
36
|
+
"""Return the autoposter version string, or None if unavailable.
|
|
37
|
+
|
|
38
|
+
Reads env first, then package.json. Result is cached for the process
|
|
39
|
+
lifetime since the version never changes mid-run.
|
|
40
|
+
"""
|
|
41
|
+
global _cached, _cached_loaded
|
|
42
|
+
if _cached_loaded:
|
|
43
|
+
return _cached
|
|
44
|
+
|
|
45
|
+
env_val = (os.environ.get("AUTOPOSTER_VERSION") or "").strip()
|
|
46
|
+
if env_val:
|
|
47
|
+
_cached = env_val
|
|
48
|
+
_cached_loaded = True
|
|
49
|
+
return _cached
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
with open(_PKG_PATH, "r", encoding="utf-8") as f:
|
|
53
|
+
pkg = json.load(f)
|
|
54
|
+
v = pkg.get("version")
|
|
55
|
+
if isinstance(v, str) and v.strip():
|
|
56
|
+
_cached = v.strip()
|
|
57
|
+
_cached_loaded = True
|
|
58
|
+
return _cached
|
|
59
|
+
except (OSError, json.JSONDecodeError):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
_cached_loaded = True
|
|
63
|
+
_cached = None
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
# CLI: `python3 scripts/version.py` -> prints the version (or empty
|
|
69
|
+
# line). Used by shell scripts that want to thread the value into env
|
|
70
|
+
# before spawning sub-processes.
|
|
71
|
+
v = read_version()
|
|
72
|
+
print(v or "")
|