social-autoposter 1.6.24 → 1.6.26

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.
Files changed (43) hide show
  1. package/bin/cli.js +12 -6
  2. package/package.json +20 -1
  3. package/scripts/score_twitter_candidates.py +15 -4
  4. package/skill/lib/twitter-backend.sh +62 -0
  5. package/skill/run-twitter-cycle.sh +4 -2
  6. package/scripts/_gsc_roi_query.py +0 -231
  7. package/scripts/_insert_post_013.py +0 -125
  8. package/scripts/_insert_post_020.py +0 -145
  9. package/scripts/_insert_post_023.py +0 -127
  10. package/scripts/_log_cyrano_apartmenthacks.py +0 -54
  11. package/scripts/_redditlink_finalize.py +0 -50
  12. package/scripts/_seo_lane_roi.py +0 -340
  13. package/scripts/batch_send_dms.py +0 -148
  14. package/scripts/check_improve_runs.py +0 -32
  15. package/scripts/classify_all_dms.py +0 -163
  16. package/scripts/finalize_post_101.py +0 -170
  17. package/scripts/insert_li_notifs.py +0 -69
  18. package/scripts/insert_post029.py +0 -89
  19. package/scripts/insert_post_024.py +0 -110
  20. package/scripts/insert_post_026.py +0 -103
  21. package/scripts/insert_post_039.py +0 -80
  22. package/scripts/insert_post_051.py +0 -85
  23. package/scripts/insert_post_059.py +0 -88
  24. package/scripts/insert_post_072.py +0 -128
  25. package/scripts/insert_post_074.py +0 -121
  26. package/scripts/insert_post_082.py +0 -82
  27. package/scripts/li_notif_scan_process.py +0 -204
  28. package/scripts/migrate_dm_links.py +0 -128
  29. package/scripts/migrate_engagement_styles_to_db.py +0 -131
  30. package/scripts/migrate_link_clicks.py +0 -97
  31. package/scripts/migrate_mentions_out_of_posts.py +0 -264
  32. package/scripts/migrate_newsletter_links.py +0 -108
  33. package/scripts/migrate_post_links.py +0 -88
  34. package/scripts/migrate_replies_stats.py +0 -45
  35. package/scripts/migrate_subreddit_bans_to_objects.py +0 -113
  36. package/scripts/phase_d_edit.py +0 -103
  37. package/scripts/phase_d_new_comments.py +0 -76
  38. package/scripts/realign_sequences.py +0 -60
  39. package/scripts/scratch_seo_gsc.py +0 -134
  40. package/scripts/scratch_seo_posthog.py +0 -179
  41. package/scripts/scratch_seo_volume.py +0 -136
  42. package/scripts/seed_dashboard_users.py +0 -94
  43. package/scripts/send_dashboard_invite.py +0 -141
package/bin/cli.js CHANGED
@@ -34,10 +34,16 @@ const ENV_TEMPLATE = `# social-autoposter environment variables
34
34
  # Get it from: https://www.moltbook.com/settings/api
35
35
  MOLTBOOK_API_KEY=
36
36
 
37
- # Postgres connection string. Bring your own Postgres DB, apply schema with:
38
- # psql "$DATABASE_URL" -f schema-postgres.sql
39
- # Format: postgresql://<user>:<password>@<host>/<db>?sslmode=require
40
- DATABASE_URL=
37
+ # s4l.ai HTTP API. The pipelines read and write all state through this API
38
+ # (no Postgres required). Defaults to https://s4l.ai when unset; set
39
+ # AUTOPOSTER_API_KEY only if your install uses a bearer token.
40
+ # AUTOPOSTER_API_BASE=https://s4l.ai
41
+ # AUTOPOSTER_API_KEY=
42
+
43
+ # Optional. Only the local dashboard (bin/server.js) still reads Postgres
44
+ # directly; the posting pipelines do not. Leave blank unless you run the
45
+ # dashboard. Format: postgresql://<user>:<password>@<host>/<db>?sslmode=require
46
+ # DATABASE_URL=
41
47
  `;
42
48
 
43
49
  // Never overwrite these user files during update
@@ -772,7 +778,7 @@ function init() {
772
778
  const envDest = path.join(DEST, '.env');
773
779
  if (!fs.existsSync(envDest)) {
774
780
  fs.writeFileSync(envDest, ENV_TEMPLATE);
775
- console.log(' created .env from template (fill in DATABASE_URL and MOLTBOOK_API_KEY)');
781
+ console.log(' created .env from template (fill in MOLTBOOK_API_KEY; AUTOPOSTER_API_KEY only if your install uses one)');
776
782
  } else {
777
783
  console.log(' .env exists — skipping');
778
784
  }
@@ -798,7 +804,7 @@ function init() {
798
804
  console.log(' 1. Edit ~/social-autoposter/config.json with your accounts');
799
805
  console.log(' 2. Tell your Claude agent: "set up social autoposter"');
800
806
  console.log(' (uses the setup/SKILL.md wizard for browser login verification)');
801
- console.log(' 3. Posts are logged to the shared Postgres DB (DATABASE_URL in .env)');
807
+ console.log(' 3. Posts and all pipeline state sync via the s4l.ai HTTP API (no Postgres required)');
802
808
  }
803
809
 
804
810
  function update() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.24",
3
+ "version": "1.6.26",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js"
@@ -14,6 +14,25 @@
14
14
  "bin/",
15
15
  "scripts/*.py",
16
16
  "!scripts/tmp_*.py",
17
+ "!scripts/insert_post*.py",
18
+ "!scripts/_insert_post_*.py",
19
+ "!scripts/_log_cyrano_*.py",
20
+ "!scripts/_redditlink_finalize.py",
21
+ "!scripts/_seo_lane_roi.py",
22
+ "!scripts/check_improve_runs.py",
23
+ "!scripts/classify_all_dms.py",
24
+ "!scripts/migrate_*.py",
25
+ "!scripts/_gsc_roi_query.py",
26
+ "!scripts/batch_send_dms.py",
27
+ "!scripts/finalize_post_*.py",
28
+ "!scripts/insert_li_notifs.py",
29
+ "!scripts/li_notif_scan_process.py",
30
+ "!scripts/phase_d_edit.py",
31
+ "!scripts/phase_d_new_comments.py",
32
+ "!scripts/realign_sequences.py",
33
+ "!scripts/scratch_*.py",
34
+ "!scripts/seed_dashboard_users.py",
35
+ "!scripts/send_dashboard_invite.py",
17
36
  "scripts/*.sh",
18
37
  "schema-postgres.sql",
19
38
  "config.example.json",
@@ -30,6 +30,16 @@ from twitter_account import resolve_handle as _resolve_twitter_handle # noqa: E
30
30
  from project_topics import topics_for_project # noqa: E402
31
31
 
32
32
 
33
+ # Freshness window (in hours) for the expire-stale gate that flips stale
34
+ # pending rows to status='expired'. Sourced from the FRESHNESS_HOURS env the
35
+ # cycle exports (run-twitter-cycle.sh) so the expiry ceiling is configured in
36
+ # ONE place. Falls back to 18 when unset (e.g. ad-hoc / --expire-only runs) to
37
+ # preserve the historical default. NOTE: the gate is on discovered_at
38
+ # (discovery age), not tweet_posted_at; for logic D (≤1h discovery freshness)
39
+ # the two are within ~1h of each other.
40
+ EXPIRE_FRESHNESS_HOURS = int(os.environ.get("FRESHNESS_HOURS") or "18")
41
+
42
+
33
43
  # Real Twitter snowflake IDs are 18-19 digit numbers with full entropy in the
34
44
  # low bits (sequence counter + worker/datacenter ID = bottom 22 bits ≈ bottom
35
45
  # 7 decimal digits). An ID ending in 6+ zeros is statistically impossible
@@ -505,9 +515,10 @@ def upsert_candidates(tweets, config, batch_id=None, attempts_map=None, scored_s
505
515
  print(f" Error inserting {url}: {e}", file=sys.stderr)
506
516
  continue
507
517
 
508
- # Expire old pending candidates (> 18h). This is a freshness GATE
509
- # (status flip), not a delete — we keep the row forever for analytics.
510
- api_post("/api/v1/twitter-candidates/expire-stale", {"freshness_hours": 18})
518
+ # Expire old pending candidates past the freshness window. This is a
519
+ # freshness GATE (status flip), not a delete — we keep the row forever
520
+ # for analytics.
521
+ api_post("/api/v1/twitter-candidates/expire-stale", {"freshness_hours": EXPIRE_FRESHNESS_HOURS})
511
522
 
512
523
  # NO PRUNING. We keep every twitter_candidates row forever (chosen, skipped,
513
524
  # expired) so we can audit project routing, skip reasons, growth dynamics,
@@ -570,7 +581,7 @@ def main():
570
581
  # it off and prints the count.
571
582
  resp = api_post(
572
583
  "/api/v1/twitter-candidates/expire-stale",
573
- {"freshness_hours": 18},
584
+ {"freshness_hours": EXPIRE_FRESHNESS_HOURS},
574
585
  )
575
586
  expired = (resp.get("data") or {}).get("expired_count", 0)
576
587
  print(f"Expired {expired} old pending candidates (no row deletion)")
@@ -227,3 +227,65 @@ defer_if_foreign_for_backend() {
227
227
  # the twitter-agent profile. Always return 1 (do not defer).
228
228
  return 1
229
229
  }
230
+
231
+ # --- browser-harness `-c` capability self-heal (added 2026-06-02) -----------
232
+ # A stale ~/Developer/browser-harness checkout that PREDATES the `-c` interface
233
+ # makes `browser-harness -c "<script>"` print its usage string instead of
234
+ # running the script. The Phase 1 scan loop in run-twitter-cycle.sh then yields
235
+ # zero tweets with no obvious cause. cli.js documents the same failure for the
236
+ # bh_run MCP path. When this bit the testing machine, the debugging agent saw
237
+ # the `-c` flag, WRONGLY assumed it was unsupported, and proposed rewriting the
238
+ # call to a nonexistent "stdin form" (browser-harness has no stdin mode — `-c`
239
+ # is the only interface; see run.py). This runs at source-time, before any
240
+ # `-c` call, so all twitter harness scripts (cycle/threads/engage/dm/followups)
241
+ # get auto-repair. Static probe is one grep when fresh (zero steady-state cost);
242
+ # the git+uv refresh only fires when the checkout is actually stale.
243
+ _sa_harness_log() {
244
+ # Use the caller's log() FUNCTION when present; `declare -F` matches only a
245
+ # shell function, never the macOS /usr/bin/log binary (command -v would).
246
+ if declare -F log >/dev/null 2>&1; then log "$*"; else echo "[$(date +%H:%M:%S)] $*" >&2; fi
247
+ }
248
+ _sa_resolve_uv() {
249
+ local c
250
+ c="$(command -v uv 2>/dev/null)" && { echo "$c"; return 0; }
251
+ for c in "$HOME/.local/bin/uv" /opt/homebrew/bin/uv /usr/local/bin/uv; do
252
+ [ -x "$c" ] && { echo "$c"; return 0; }
253
+ done
254
+ return 1
255
+ }
256
+ ensure_harness_c_support() {
257
+ # Run once per process even if several libs source this file.
258
+ [ -n "${_SA_HARNESS_C_CHECKED:-}" ] && return 0
259
+ export _SA_HARNESS_C_CHECKED=1
260
+
261
+ local src="$HOME/Developer/browser-harness"
262
+ local run_py="$src/src/browser_harness/run.py"
263
+ local bh="$HOME/.local/bin/browser-harness"
264
+
265
+ # Static capability probe — no daemon/Chrome needed. The CLI is an editable
266
+ # (`uv tool install -e`) install of $src, so its run.py is the source of
267
+ # truth for whether `-c` is recognized.
268
+ if [ -f "$run_py" ] && grep -q '"-c"' "$run_py"; then
269
+ return 0
270
+ fi
271
+
272
+ _sa_harness_log "[harness] browser-harness checkout missing/stale (no -c support) -> self-healing via git + uv..."
273
+ if [ -d "$src/.git" ]; then
274
+ git -C "$src" fetch --depth 1 origin HEAD >/dev/null 2>&1 \
275
+ && git -C "$src" reset --hard FETCH_HEAD >/dev/null 2>&1
276
+ fi
277
+ local uv; uv="$(_sa_resolve_uv || true)"
278
+ if [ -n "$uv" ] && [ -d "$src" ]; then
279
+ "$uv" tool install --force -e "$src" >/dev/null 2>&1 || true
280
+ fi
281
+ [ -x "$bh" ] && "$bh" --reload >/dev/null 2>&1 || true
282
+
283
+ if [ -f "$run_py" ] && grep -q '"-c"' "$run_py"; then
284
+ _sa_harness_log "[harness] self-heal OK -> browser-harness -c is supported now"
285
+ return 0
286
+ fi
287
+ _sa_harness_log "[harness] ERROR: browser-harness still lacks -c after self-heal."
288
+ _sa_harness_log "[harness] FIX: run 'social-autoposter update' (re-clones/refreshes $src + reinstalls the CLI). The -c flag is CORRECT; browser-harness has NO stdin mode, so do NOT rewrite the cycle to a stdin form."
289
+ return 1
290
+ }
291
+ ensure_harness_c_support || true
@@ -147,7 +147,9 @@ FRESHNESS_HOURS=2
147
147
  # since-rewrite hook) stays tightened to 1h, the winning D setting.
148
148
  TWITTER_CYCLE_VARIANT=D
149
149
  FRESHNESS_HOURS_DISCOVER=1
150
- export TWITTER_CYCLE_VARIANT FRESHNESS_HOURS_DISCOVER
150
+ # Export FRESHNESS_HOURS too so score_twitter_candidates.py inherits it and
151
+ # drives the expire-stale gate from the same knob (was hardcoded 18h there).
152
+ export TWITTER_CYCLE_VARIANT FRESHNESS_HOURS_DISCOVER FRESHNESS_HOURS
151
153
  # Hook env: ~/.claude/hooks/twitter-search-since-rewrite.py reads this and
152
154
  # uses it in place of its hardcoded 6h default when present.
153
155
  export FRESHNESS_HOURS_OVERRIDE=$FRESHNESS_HOURS_DISCOVER
@@ -1002,7 +1004,7 @@ if [ "$QUERIES_COUNT" -gt 0 ]; then
1002
1004
  ENGAGED_TWEET_IDS="$ENGAGED_TWEET_IDS" \
1003
1005
  "$HOME/.local/bin/browser-harness" -c "
1004
1006
  import sys, json, os, time
1005
- sys.path.insert(0, '/Users/matthewdi/social-autoposter/scripts')
1007
+ sys.path.insert(0, '$REPO_DIR/scripts')
1006
1008
  from twitter_scan import scan
1007
1009
  queries = json.load(open('$QUERIES_TMP'))
1008
1010
  freshness = int(os.environ.get('FRESHNESS_HOURS_DISCOVER', '6'))
@@ -1,231 +0,0 @@
1
- #!/usr/bin/env python3
2
- """One-off: GSC pipeline ROI per project (last 30d).
3
-
4
- For each project in config.json with GSC-pipeline pages in the last 30 days,
5
- queries PostHog (HogQL) for total unique-distinct_id visitors to those exact
6
- $pathname values, and the top-3 pages by visitors. Joins with Postgres for
7
- GSC clicks and spend.
8
-
9
- Run with:
10
- /opt/homebrew/bin/python3.11 scripts/_gsc_roi_query.py
11
- """
12
- import json
13
- import os
14
- import subprocess
15
- import sys
16
- import urllib.parse
17
- import urllib.request
18
- from collections import defaultdict
19
-
20
- ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
21
-
22
-
23
- def load_env():
24
- with open(os.path.join(ROOT, '.env')) as f:
25
- for line in f:
26
- line = line.strip()
27
- if line and not line.startswith('#') and '=' in line:
28
- k, v = line.split('=', 1)
29
- os.environ.setdefault(k.strip(), v.strip())
30
-
31
-
32
- def ph_key():
33
- return subprocess.check_output(
34
- ['security', 'find-generic-password', '-s', 'PostHog-Personal-API-Key-m13v', '-w'],
35
- text=True,
36
- ).strip()
37
-
38
-
39
- def hogql(project_id: str, sql: str, key: str):
40
- body = json.dumps({"query": {"kind": "HogQLQuery", "query": sql}}).encode()
41
- req = urllib.request.Request(
42
- f"https://us.posthog.com/api/projects/{project_id}/query/",
43
- data=body,
44
- headers={
45
- "Authorization": f"Bearer {key}",
46
- "Content-Type": "application/json",
47
- },
48
- method="POST",
49
- )
50
- with urllib.request.urlopen(req, timeout=60) as r:
51
- return json.loads(r.read().decode())
52
-
53
-
54
- def main():
55
- load_env()
56
- import psycopg2
57
-
58
- # Build project -> posthog project_id map from config.json
59
- with open(os.path.join(ROOT, 'config.json')) as f:
60
- cfg = json.load(f)
61
- posthog_map = {}
62
- site_map = {}
63
- for p in cfg['projects']:
64
- name = p['name']
65
- ph = (p.get('posthog') or {}).get('project_id')
66
- if ph:
67
- posthog_map[name.lower()] = str(ph)
68
- site_map[name.lower()] = (p.get('landing_pages') or {}).get('base_url') or p.get('website') or ''
69
-
70
- # Pull GSC pages + spend per project (last 30d)
71
- conn = psycopg2.connect(os.environ['DATABASE_URL'])
72
- cur = conn.cursor()
73
- cur.execute("""
74
- SELECT gq.product, gq.page_url, gq.clicks, gq.impressions,
75
- COALESCE(cs.total_cost_usd, 0)::float AS cost
76
- FROM gsc_queries gq
77
- LEFT JOIN claude_sessions cs ON cs.session_id = gq.claude_session_id
78
- WHERE gq.status='done'
79
- AND gq.completed_at >= NOW() - INTERVAL '30 days'
80
- AND gq.page_url IS NOT NULL AND gq.page_url <> ''
81
- """)
82
- rows = cur.fetchall()
83
- cur.close(); conn.close()
84
-
85
- # Group by product
86
- per_project = defaultdict(list)
87
- for product, url, clicks, impr, cost in rows:
88
- # Normalize: if URL is relative, leave path; if absolute, parse path
89
- if url.startswith('http'):
90
- u = urllib.parse.urlparse(url)
91
- host = u.netloc
92
- path = u.path or '/'
93
- else:
94
- host = ''
95
- path = url
96
- per_project[product].append({
97
- "host": host, "path": path, "clicks": int(clicks or 0),
98
- "impressions": int(impr or 0), "cost": float(cost or 0),
99
- })
100
-
101
- key = ph_key()
102
- summary = []
103
-
104
- for product, pages in per_project.items():
105
- ph_proj = posthog_map.get(product.lower())
106
- n_pages = len(pages)
107
- spend = sum(p['cost'] for p in pages)
108
- gsc_clicks = sum(p['clicks'] for p in pages)
109
- gsc_impr = sum(p['impressions'] for p in pages)
110
-
111
- if not ph_proj:
112
- summary.append({
113
- "product": product, "n_pages": n_pages, "spend": spend,
114
- "visitors": None, "gsc_clicks": gsc_clicks, "gsc_impr": gsc_impr,
115
- "top": [], "note": "no posthog project_id in config.json",
116
- })
117
- continue
118
-
119
- # PostHog HogQL: count unique distinct_id per $pathname for the matching paths.
120
- # Filter by path IN (...) — host doesn't matter inside a single PostHog project.
121
- paths = sorted({p['path'] for p in pages if p['path']})
122
- if not paths:
123
- summary.append({
124
- "product": product, "n_pages": n_pages, "spend": spend,
125
- "visitors": 0, "gsc_clicks": gsc_clicks, "gsc_impr": gsc_impr,
126
- "top": [], "note": "no paths to query",
127
- })
128
- continue
129
- path_list = ", ".join(f"'{p.replace(chr(39), chr(39)+chr(39))}'" for p in paths)
130
-
131
- sql = (
132
- "SELECT properties.$pathname AS path, count(DISTINCT distinct_id) AS visitors "
133
- "FROM events "
134
- "WHERE event = '$pageview' "
135
- f"AND timestamp > now() - INTERVAL 30 DAY "
136
- f"AND properties.$pathname IN ({path_list}) "
137
- "GROUP BY properties.$pathname "
138
- "ORDER BY visitors DESC "
139
- "LIMIT 500"
140
- )
141
- try:
142
- res = hogql(ph_proj, sql, key)
143
- results = res.get('results') or []
144
- total_visitors = sum(r[1] for r in results)
145
- top = [(r[0], r[1]) for r in results[:5]]
146
- except Exception as e:
147
- total_visitors = None
148
- top = []
149
- err = str(e)[:120]
150
- summary.append({
151
- "product": product, "n_pages": n_pages, "spend": spend,
152
- "visitors": None, "gsc_clicks": gsc_clicks, "gsc_impr": gsc_impr,
153
- "top": [], "note": f"PostHog error: {err}",
154
- })
155
- continue
156
-
157
- summary.append({
158
- "product": product, "n_pages": n_pages, "spend": spend,
159
- "visitors": total_visitors, "gsc_clicks": gsc_clicks, "gsc_impr": gsc_impr,
160
- "top": top, "ph_proj": ph_proj, "note": "",
161
- })
162
-
163
- # Sort by visitors desc (None at bottom)
164
- summary.sort(key=lambda s: (s['visitors'] is None, -(s['visitors'] or 0)))
165
-
166
- print()
167
- print(f"{'Project':<14} {'Pages':>5} {'Spend':>9} {'Visitors':>9} {'GSCclicks':>9} {'GSCimpr':>8} {'$/visitor':>10} Top page (visitors)")
168
- print("-" * 130)
169
- for s in summary:
170
- v = s['visitors']
171
- vs = "n/a" if v is None else f"{v:,}"
172
- cpv = "n/a" if not v else f"${s['spend']/v:.2f}"
173
- top1 = s['top'][0] if s['top'] else ("", 0)
174
- top1_str = f"{top1[0][:60]} ({top1[1]})" if top1[0] else "-"
175
- print(f"{s['product']:<14} {s['n_pages']:>5} ${s['spend']:>7.0f} {vs:>9} {s['gsc_clicks']:>9} {s['gsc_impr']:>8} {cpv:>10} {top1_str}")
176
- if s['note']:
177
- print(f" note: {s['note']}")
178
-
179
- print("\n\nTOP-3 GSC PAGES PER PROJECT BY POSTHOG VISITORS:")
180
- print("=" * 100)
181
- for s in summary:
182
- if not s['top']:
183
- continue
184
- print(f"\n{s['product']} (ph={s.get('ph_proj','?')}):")
185
- for path, v in s['top'][:3]:
186
- print(f" {v:>6,} {path}")
187
-
188
- # Now for the top-N pages overall across all projects, break down by $referrer / utm_source
189
- print("\n\nTRAFFIC SOURCE BREAKDOWN (Top 5 GSC pages by visitors, per project):")
190
- print("=" * 100)
191
- for s in summary:
192
- if not s['top']:
193
- continue
194
- ph_proj = s.get('ph_proj')
195
- if not ph_proj:
196
- continue
197
- top_paths = [p for p, _ in s['top'][:5]]
198
- path_list = ", ".join(f"'{p.replace(chr(39), chr(39)+chr(39))}'" for p in top_paths)
199
- sql = (
200
- "SELECT properties.$pathname AS path, "
201
- "coalesce(properties.$referring_domain, 'direct/none') AS ref, "
202
- "count(DISTINCT distinct_id) AS visitors "
203
- "FROM events "
204
- "WHERE event = '$pageview' "
205
- "AND timestamp > now() - INTERVAL 30 DAY "
206
- f"AND properties.$pathname IN ({path_list}) "
207
- "GROUP BY path, ref "
208
- "ORDER BY path, visitors DESC "
209
- "LIMIT 200"
210
- )
211
- try:
212
- r = hogql(ph_proj, sql, key)
213
- res = r.get('results') or []
214
- except Exception as e:
215
- print(f"\n{s['product']}: PostHog source-breakdown error: {str(e)[:80]}")
216
- continue
217
- by_path = defaultdict(list)
218
- for path, ref, v in res:
219
- by_path[path].append((ref, v))
220
- print(f"\n{s['product']}:")
221
- for path in top_paths:
222
- srcs = by_path.get(path) or []
223
- srcs.sort(key=lambda x: -x[1])
224
- total = sum(v for _, v in srcs)
225
- head = ", ".join(f"{ref}={v}" for ref, v in srcs[:5])
226
- print(f" [{total:>5,}] {path}")
227
- print(f" {head}")
228
-
229
-
230
- if __name__ == "__main__":
231
- main()
@@ -1,125 +0,0 @@
1
- #!/usr/bin/env python3.11
2
- """One-shot insert/update for post-013 (Mixer-spa rerun, product type)."""
3
- import json
4
- import os
5
- import sys
6
- from pathlib import Path
7
-
8
- import psycopg2
9
- from dotenv import load_dotenv
10
-
11
- load_dotenv(Path.home() / "social-autoposter" / ".env")
12
-
13
- POST_NUMBER = 13
14
- VARIANT_ID = "spa"
15
- COMPOSITION_ID = "Mixer-spa"
16
- VIDEO_PATH = str(Path.home() / "social-autoposter/mixer/remotion/out/post-013.mp4")
17
- CAPTION_PATH = Path.home() / "social-autoposter/mixer/remotion/out/post-013.caption.txt"
18
- AUDIO_SOURCE = "local:~/social-autoposter/mixer/audio/track-011_iphone-E1760FF1.m4a"
19
- THEME_ANGLE = "walk-in spa sales ($400/visit)"
20
-
21
- # Build source_clips with cumulative timing (spa variant pipeline).
22
- spa_clips = [
23
- ("mixer/intro-1.mp4", 4.633),
24
- ("mixer/google-maps.mp4", 1.867),
25
- ("mixer/search-spa.mp4", 5.133),
26
- ("mixer/mk0r.mp4", 4.833),
27
- ("mixer/prompt-spa.mp4", 2.867),
28
- ("mixer/impressed-1.mp4", 3.467),
29
- ("mixer/result-spa.mp4", 3.033),
30
- ("mixer/mk0r-publish.mp4", 1.2),
31
- ]
32
- source_clips = []
33
- t = 0.0
34
- for i, (src, dur) in enumerate(spa_clips, 1):
35
- source_clips.append({
36
- "src": src,
37
- "order": i,
38
- "start_sec": round(t, 3),
39
- "end_sec": round(t + dur, 3),
40
- "src_dur_sec": dur,
41
- "target_dur_sec": dur,
42
- "speedup": 1.0,
43
- })
44
- t += dur
45
- total_sec = round(t, 3)
46
-
47
- overlays = [
48
- {"kind": "title", "text": "how to make $20K / month in 2026", "order": 1, "start_sec": 0.0, "end_sec": 4.633, "dur_sec": 4.633},
49
- {"text": "find a business with no website", "order": 2, "start_sec": 4.633, "end_sec": 11.633, "dur_sec": 7.0},
50
- {"text": "go to mk0r.com", "order": 3, "start_sec": 11.633, "end_sec": 16.466, "dur_sec": 4.833},
51
- {"text": "prompt it to build the site", "order": 4, "start_sec": 16.466, "end_sec": 19.333, "dur_sec": 2.867},
52
- {"text": "publish it and call them up", "order": 5, "start_sec": 22.8, "end_sec": 27.033, "dur_sec": 4.233},
53
- ]
54
-
55
- metadata = {
56
- "theme": "mk0r",
57
- "format": "mixer",
58
- "clip_count": len(source_clips),
59
- "source_repo": "social-autoposter",
60
- "theme_angle": THEME_ANGLE,
61
- "theme_label": "spa",
62
- "caption_style": "v4-here-is-a-story",
63
- "overlay_count": len(overlays),
64
- "composition_id": COMPOSITION_ID,
65
- "description_style": "narrative-story-arc",
66
- }
67
-
68
- caption_text = CAPTION_PATH.read_text(encoding="utf-8")
69
-
70
- conn = psycopg2.connect(os.environ["DATABASE_URL"])
71
- cur = conn.cursor()
72
-
73
- cur.execute("SELECT post_number FROM media_posts WHERE post_number=%s", (POST_NUMBER,))
74
- exists = cur.fetchone() is not None
75
-
76
- if exists:
77
- cur.execute(
78
- """
79
- UPDATE media_posts SET
80
- variant_id=%s,
81
- project_name=%s,
82
- post_type=%s,
83
- video_path=%s,
84
- audio_source=%s,
85
- caption_text=%s,
86
- caption_version='v4-story',
87
- duration_sec=%s,
88
- width=1080,
89
- height=1920,
90
- status='draft',
91
- source_clips=%s::jsonb,
92
- overlays=%s::jsonb,
93
- metadata=%s::jsonb
94
- WHERE post_number=%s
95
- """,
96
- (
97
- VARIANT_ID, "mk0r", "product", VIDEO_PATH, AUDIO_SOURCE, caption_text,
98
- total_sec, json.dumps(source_clips), json.dumps(overlays),
99
- json.dumps(metadata), POST_NUMBER,
100
- ),
101
- )
102
- print(f"UPDATED row post_number={POST_NUMBER}")
103
- else:
104
- cur.execute(
105
- """
106
- INSERT INTO media_posts
107
- (post_number, variant_id, project_name, post_type, video_path, audio_source,
108
- caption_text, caption_version, duration_sec, width, height, status,
109
- source_clips, overlays, metadata)
110
- VALUES
111
- (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, %s::jsonb)
112
- """,
113
- (
114
- POST_NUMBER, VARIANT_ID, "mk0r", "product", VIDEO_PATH, AUDIO_SOURCE,
115
- caption_text, "v4-story", total_sec, 1080, 1920, "draft",
116
- json.dumps(source_clips), json.dumps(overlays), json.dumps(metadata),
117
- ),
118
- )
119
- print(f"INSERTED row post_number={POST_NUMBER}")
120
-
121
- conn.commit()
122
- cur.close()
123
- conn.close()
124
- print(f"video_path={VIDEO_PATH}")
125
- print(f"duration={total_sec}s clips={len(source_clips)} overlays={len(overlays)}")