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.
- package/bin/cli.js +12 -6
- package/package.json +20 -1
- package/scripts/score_twitter_candidates.py +15 -4
- package/skill/lib/twitter-backend.sh +62 -0
- package/skill/run-twitter-cycle.sh +4 -2
- package/scripts/_gsc_roi_query.py +0 -231
- package/scripts/_insert_post_013.py +0 -125
- package/scripts/_insert_post_020.py +0 -145
- package/scripts/_insert_post_023.py +0 -127
- package/scripts/_log_cyrano_apartmenthacks.py +0 -54
- package/scripts/_redditlink_finalize.py +0 -50
- package/scripts/_seo_lane_roi.py +0 -340
- package/scripts/batch_send_dms.py +0 -148
- package/scripts/check_improve_runs.py +0 -32
- package/scripts/classify_all_dms.py +0 -163
- package/scripts/finalize_post_101.py +0 -170
- package/scripts/insert_li_notifs.py +0 -69
- package/scripts/insert_post029.py +0 -89
- package/scripts/insert_post_024.py +0 -110
- package/scripts/insert_post_026.py +0 -103
- package/scripts/insert_post_039.py +0 -80
- package/scripts/insert_post_051.py +0 -85
- package/scripts/insert_post_059.py +0 -88
- package/scripts/insert_post_072.py +0 -128
- package/scripts/insert_post_074.py +0 -121
- package/scripts/insert_post_082.py +0 -82
- package/scripts/li_notif_scan_process.py +0 -204
- package/scripts/migrate_dm_links.py +0 -128
- package/scripts/migrate_engagement_styles_to_db.py +0 -131
- package/scripts/migrate_link_clicks.py +0 -97
- package/scripts/migrate_mentions_out_of_posts.py +0 -264
- package/scripts/migrate_newsletter_links.py +0 -108
- package/scripts/migrate_post_links.py +0 -88
- package/scripts/migrate_replies_stats.py +0 -45
- package/scripts/migrate_subreddit_bans_to_objects.py +0 -113
- package/scripts/phase_d_edit.py +0 -103
- package/scripts/phase_d_new_comments.py +0 -76
- package/scripts/realign_sequences.py +0 -60
- package/scripts/scratch_seo_gsc.py +0 -134
- package/scripts/scratch_seo_posthog.py +0 -179
- package/scripts/scratch_seo_volume.py +0 -136
- package/scripts/seed_dashboard_users.py +0 -94
- 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
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
509
|
-
# (status flip), not a delete — we keep the row forever
|
|
510
|
-
|
|
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":
|
|
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
|
-
|
|
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, '/
|
|
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)}")
|