luckiest-co 1.0.53 → 1.0.55

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 (29) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/video-studio/ATTRIBUTION.md +1 -1
  5. package/skills/video-studio/CHANGELOG.md +10 -0
  6. package/skills/video-studio/SKILL.md +3 -3
  7. package/skills/video-studio/references/audio.md +9 -0
  8. package/skills/video-studio/references/lessons.md +11 -0
  9. package/skills/video-studio-cards/ATTRIBUTION.md +1 -1
  10. package/skills/video-studio-cuts/ATTRIBUTION.md +1 -1
  11. package/skills/video-studio-cuts/CHANGELOG.md +8 -0
  12. package/skills/video-studio-cuts/SKILL.md +15 -5
  13. package/skills/video-studio-cuts/scripts/find-cut-candidates.mjs +45 -5
  14. package/skills/video-studio-cuts/scripts/find-cut-candidates.test.mjs +43 -0
  15. package/skills/video-studio-reel/ATTRIBUTION.md +1 -1
  16. package/skills/video-studio-script/CHANGELOG.md +5 -0
  17. package/skills/video-studio-script/SKILL.md +12 -3
  18. package/skills/video-studio-talk/ATTRIBUTION.md +1 -1
  19. package/skills/video-studio-talk/CHANGELOG.md +9 -0
  20. package/skills/video-studio-talk/SKILL.md +27 -6
  21. package/skills/video-studio-talk/references/house-reel.md +114 -0
  22. package/skills/youtube-planner/ATTRIBUTION.md +9 -3
  23. package/skills/youtube-planner/CHANGELOG.md +11 -0
  24. package/skills/youtube-planner/SKILL.md +89 -23
  25. package/skills/youtube-planner/references/plan-template.md +9 -5
  26. package/skills/youtube-planner/references/stages.md +20 -11
  27. package/skills/youtube-planner/scripts/floor_ceiling.py +71 -1
  28. package/skills/youtube-planner/scripts/pairing.py +121 -0
  29. package/skills/youtube-planner/scripts/retention.py +142 -0
@@ -20,10 +20,10 @@ description: >-
20
20
  argument-hint: "[topic or video idea] [--channel <url>] [--readout <video-url>]"
21
21
  user-invocable: true
22
22
  license: MIT. See ATTRIBUTION.md
23
- compatibility: Python 3.10+ and yt-dlp for the floor and ceiling check. Works without them, with the user's own numbers.
23
+ compatibility: Python 3.10+ for the scripts, and yt-dlp for the floor, ceiling, and channel outlier checks. Works without them, with the user's own numbers.
24
24
  allowed-tools: Bash, Read, Write, Glob, Grep, AskUserQuestion
25
25
  metadata:
26
- version: "1.1.0"
26
+ version: "1.2.0"
27
27
  listing_id: luckiest-youtube-planner
28
28
  author: luckiest
29
29
  ---
@@ -44,11 +44,11 @@ first run of a session.
44
44
  ## Staying current
45
45
 
46
46
  On activation, call the Luckiest MCP `check_updates` tool with
47
- `{ listingId: "luckiest-youtube-planner", installedSemver: "1.1.0" }`. If
47
+ `{ listingId: "luckiest-youtube-planner", installedSemver: "1.2.0" }`. If
48
48
  `upToDate: false`, surface the `notice` once and continue. Never block on it.
49
49
 
50
50
  When done, call `report_usage` once with
51
- `{ listing_id: "luckiest-youtube-planner", skill_version: "1.1.0", matched: true, success: <true|false> }`.
51
+ `{ listing_id: "luckiest-youtube-planner", skill_version: "1.2.0", matched: true, success: <true|false> }`.
52
52
  Metadata only, never prompt text. Skip silently if unavailable.
53
53
 
54
54
  ## Standing rules
@@ -77,6 +77,10 @@ already said or `youtube-plan.md` already holds:
77
77
  sales, build authority, or test a format.
78
78
  3. Do you have a channel URL? It is optional and only used for your own median views.
79
79
 
80
+ If `voice.md` exists in the working directory or `~/.claude/youtube/`, read it
81
+ before writing any spoken line: the hook has to sound like the person saying it.
82
+ Do not create one unless asked.
83
+
80
84
  If the user brought a published video and asked why it flopped or how it is
81
85
  doing, jump to Step 6 with `--readout`.
82
86
 
@@ -99,27 +103,51 @@ Pick topics the audience already chooses to watch, not topics they say they need
99
103
  3. Drop a topic when its floor is not worth the effort for the goal, when most
100
104
  views are older than 12 months (stale demand), or when the counts look
101
105
  bought (huge views, almost no comments).
102
- 4. Raise the ceiling: try bundling the topic with a bigger draw (a known name,
106
+ 4. With a channel URL, or two or three channels the audience already watches,
107
+ find what beat its own channel:
108
+
109
+ ```bash
110
+ python3 "${SKILL_DIR}/scripts/floor_ceiling.py" --channel '<url>' --channel '<url>' --limit 30
111
+ ```
112
+
113
+ It ranks videos by their multiple over their own channel's median and skips
114
+ channels with fewer than 4 counted videos. A 5x video on a small channel says
115
+ more about the idea than an ordinary video on a big one.
116
+ 5. Raise the ceiling: try bundling the topic with a bigger draw (a known name,
103
117
  a broader problem, a time hook) and rerun the script on the bundled phrase.
104
118
  Keep the bundle only if the floor holds and the video can still deliver it.
105
- 5. Score the chosen idea 1 to 10 with the rubric in stages.md.
119
+ 6. Score the chosen idea 1 to 10 with the rubric in stages.md.
106
120
 
107
121
  ## Step 3: Packaging (stage 2)
108
122
 
109
123
  The video sits beside about 20 others in home or search. It has to be noticed,
110
124
  then understood, then clicked, before the eye moves on.
111
125
 
112
- 1. Write 5 title options using framings that win across niches. Name the
126
+ 1. Write 10 title options using framings that win across niches. Name the
113
127
  pattern and the reason it works for this audience on each.
114
- 2. Pick the best 2 and pair each with a one-line thumbnail concept: what stands
115
- out at phone size, and what the viewer understands at a glance. The title
116
- and the thumbnail must say different things. The thumbnail opens the
117
- question, and the title closes it just enough to earn the click.
118
- 3. Lint each pairing: under 60 characters so it does not truncate, no word the
119
- thumbnail already shows, and a promise the body will keep.
120
- 4. Gut check: is the user a little nervous about messing this up? If the idea
128
+ 2. Pair the strongest with thumbnail text (3 words at most) and a one-line
129
+ thumbnail concept: what stands out at phone size, and what the viewer
130
+ understands at a glance. The title and the thumbnail must say different
131
+ things. The thumbnail opens the question, and the title closes it just
132
+ enough to earn the click.
133
+ 3. Lint the pairings, one `title | thumb` per line in a file:
134
+
135
+ ```bash
136
+ python3 "${SKILL_DIR}/scripts/pairing.py" pairings.txt
137
+ ```
138
+
139
+ It flags the desktop cut (60 characters), the mobile cut (40), a thumbnail
140
+ that repeats a title word, more than 3 thumbnail words, more than 2 all-caps
141
+ words, vague adjectives, no number, name, or date, and filler up front. Show
142
+ the top 3 with their scores and issues, then keep 2. The lint cannot check
143
+ the promise: confirm by hand that the body will keep it.
144
+ 4. Query test: write the 3 searches a real viewer would type to find this video.
145
+ The title and the first 2 lines of the description must contain the words of
146
+ those searches. If they do not, fix the title, not the description. Tags
147
+ barely matter: keep them to spellings and names people actually type.
148
+ 5. Gut check: is the user a little nervous about messing this up? If the idea
121
149
  feels safe, it is usually too small. Say so.
122
- 5. Score it. For thumbnail images, hand off to **luckiest-image** with the
150
+ 6. Score it. For thumbnail images, hand off to **luckiest-image** with the
123
151
  chosen concept.
124
152
 
125
153
  ## Step 4: Hook (stage 3)
@@ -133,8 +161,20 @@ the doubt by answering four questions, in any order:
133
161
  - **Persona**: is this for me? Name who it is for, and who it is not for when the
134
162
  packaging could attract the wrong viewer.
135
163
 
136
- Write the hook as spoken lines and score it. A hook that only teases, with no
137
- promise and no plan, fails this stage.
164
+ Rules for the opening lines:
165
+
166
+ - The first sentence confirms the title. A hook that ignores its own title is
167
+ why viewers leave in the first 10 seconds.
168
+ - One promise. A hook that carries two keeps neither.
169
+ - Specific beats big: a number, a name, or a date, never "massive".
170
+ - If the title promises a thing (a template, a result, a tool), show it within
171
+ 20 seconds.
172
+ - No channel intro, no "who I am", and no subscribe ask before 0:45. These are
173
+ the usual cause of the drop at 0:30.
174
+
175
+ Write 3 versions as spoken lines, score each, and show the user the best 2.
176
+ Never hand over just one. A hook that only teases, with no promise and no plan,
177
+ fails this stage.
138
178
 
139
179
  ## Step 5: Body (stage 4)
140
180
 
@@ -155,9 +195,20 @@ Build the outline from the title's promise, not from everything the user knows.
155
195
  - **Reopen**: as soon as one question closes, open the next one.
156
196
  A good video has one loop. A great one has a new loop starting before the
157
197
  last one ends. Mark each loop's four beats on the outline.
158
- 5. The video is done when every item is delivered, not when it looks done. Aim
198
+ 5. Mark every beat with what is on screen, as `[ON SCREEN: ...]`. A talking head
199
+ with nothing to look at is a podcast.
200
+ 6. Name each section in 5 words or fewer. A section that cannot be named that
201
+ briefly is two sections or filler. The names become the chapters: the first
202
+ at 00:00, at least 3 of them, each at least 10 seconds long, or YouTube does
203
+ not show them.
204
+ 7. Estimate the runtime at 150 spoken words per minute.
205
+ 8. Mark 3 Short candidates on the outline: 20 to 55 seconds that open on a
206
+ complete thought, contain a turn, and end on a line, not a trail-off. Write
207
+ a new first line for each, since the original assumes context the Shorts
208
+ viewer lacks. Hand them to **luckiest-social** after the video ships.
209
+ 9. The video is done when every item is delivered, not when it looks done. Aim
159
210
  to beat the expectation. Satisfaction is the goal, and retention follows it.
160
- 6. Score it. For the spoken script, hand off to **luckiest-video-studio-script**
211
+ 10. Score it. For the spoken script, hand off to **luckiest-video-studio-script**
161
212
  with the hook and the outline.
162
213
 
163
214
  ## Step 6: Readout plan (stage 5)
@@ -171,10 +222,25 @@ Write down, before publishing, how the user will judge the result:
171
222
  - Metrics point to where to look, never to the fix. Low CTR means look at the
172
223
  pairing and ask why. A sharp retention drop means look at that exact moment.
173
224
 
174
- With `--readout <video-url>`, use **luckiest-video-watcher** in quick mode on the
175
- moments the user flags, and read the public comment count and like count from
176
- `yt-dlp --dump-json`. Report what each signal says, which stage it points to,
177
- and the one change to test next.
225
+ With `--readout <video-url>`:
226
+
227
+ 1. Read the public comment count and like count from `yt-dlp --dump-json`.
228
+ 2. If the user exports the retention chart from Studio, read it:
229
+
230
+ ```bash
231
+ python3 "${SKILL_DIR}/scripts/retention.py" retention.csv --duration <seconds> --transcript <file.srt>
232
+ ```
233
+
234
+ It reports the hook leak (first 30 seconds: under 25 points healthy, under 40
235
+ leaking, else severe), the steepest cliffs with the words spoken there, and
236
+ the slide across the middle. Use **luckiest-video-watcher** in quick mode on
237
+ the cliff moments.
238
+ 3. Sort the comments the user pastes into four piles and count each: questions
239
+ (next video topics, note the repeats), corrections (concede the right ones),
240
+ praise, and bait (ignore). Suggest pinning the question most viewers share.
241
+ 4. Report what each signal says and which stage it points to. End with the ONE
242
+ change to test next, 3 specific things that already work so they are kept,
243
+ and what not to change yet. Never a list of twenty fixes.
178
244
 
179
245
  ## Step 7: Gate and write
180
246
 
@@ -26,7 +26,8 @@ Dropped: <topic>: <reason>
26
26
  ## 2. Packaging
27
27
  | Title | Pattern | Why it works here | Thumbnail |
28
28
  |---|---|---|---|
29
- Pick: <title + thumbnail>
29
+ Pick: <title + thumbnail> · pairing.py <n>/100
30
+ Queries: <query 1> · <query 2> · <query 3> (all words in the title or first 2 description lines: yes/no)
30
31
 
31
32
  ## 3. Hook
32
33
  - Promise:
@@ -37,10 +38,12 @@ Pick: <title + thumbnail>
37
38
 
38
39
  ## 4. Body
39
40
  Must be true for the title to be true:
40
- 1. <item>
41
- - <sub-item>
42
- First value lands at: <m:ss>
41
+ 1. <section name, 5 words or fewer>
42
+ - <sub-item> [ON SCREEN: <what the viewer sees>]
43
+ First value lands at: <m:ss> · Runtime: <m:ss> at 150 wpm
43
44
  Beats expectation by: <item>
45
+ Chapters: 00:00 <name> · <m:ss> <name> · <m:ss> <name>
46
+ Shorts: <m:ss-m:ss> "<new first line>" (x3)
44
47
 
45
48
  ## 5. Readout
46
49
  Review on: <date>
@@ -50,7 +53,8 @@ Review on: <date>
50
53
  | Likes per view | | Body |
51
54
  | Subs per view | | Persona |
52
55
  | CTR | | Packaging |
53
- | Retention drops | | Hook / that moment |
56
+ | Hook leak (first 30s) | under 25 points | Hook |
57
+ | Retention cliffs | none over a few points | That moment in the body |
54
58
 
55
59
  ## Next
56
60
  <weakest stage>: <one action>
@@ -31,16 +31,18 @@ Real views check: a video with very high views but almost no comments or likes,
31
31
  or a channel whose other videos get a tiny fraction of that, is a sign of
32
32
  bought or bot views. Leave it out of the floor.
33
33
 
34
- Own-channel outliers: when the user gave a channel, compare each candidate's
35
- top videos with their channel's own median. A video at 5x its channel's median
36
- says more than a big channel's ordinary video.
34
+ Own-channel outliers: `floor_ceiling.py --channel` divides each video's views
35
+ by its own channel's median. A video at 5x its channel's median says more than
36
+ a big channel's ordinary video. Channels with fewer than 4 counted videos are
37
+ skipped, since a median of two or three is noise. The pattern the outliers
38
+ share is the signal, not any single one.
37
39
 
38
40
  ## 2. Packaging
39
41
 
40
42
  | Score | Means |
41
43
  |---|---|
42
- | 9-10 | A cross-niche pattern with a stated reason, a thumbnail readable at phone size, title and thumbnail that do not repeat each other, a promise the outline keeps |
43
- | 7-8 | All of the above, with a weaker reason or a busier thumbnail |
44
+ | 9-10 | A cross-niche pattern with a stated reason, a thumbnail readable at phone size, title and thumbnail that do not repeat each other, a promise the outline keeps, `pairing.py` at 85 or more, and all 3 queries pass the query test |
45
+ | 7-8 | All of the above, with a weaker reason, a busier thumbnail, or `pairing.py` at 70 to 84 |
44
46
  | 4-6 | Copies one outlier, or the title and thumbnail say the same thing |
45
47
  | 1-3 | Clickbait the body cannot pay off |
46
48
 
@@ -65,9 +67,9 @@ Thumbnail jobs:
65
67
 
66
68
  | Score | Means |
67
69
  |---|---|
68
- | 9-10 | All four Ps answered in under 30 seconds, proof is specific and true, plan names every step |
70
+ | 9-10 | All four Ps answered in under 30 seconds, proof is specific and true, plan names every step, the first sentence confirms the title, and nothing about the channel comes before the promise |
69
71
  | 7-8 | All four Ps, with vague proof or plan |
70
- | 4-6 | Missing one P |
72
+ | 4-6 | Missing one P, or it opens with an intro, a "who I am", or a subscribe ask |
71
73
  | 1-3 | A tease with no promise or plan |
72
74
 
73
75
  Persona matters most when the packaging could pull in two audiences. Say
@@ -77,8 +79,8 @@ plainly who it is for, so the wrong viewers leave early and the right ones stay.
77
79
 
78
80
  | Score | Means |
79
81
  |---|---|
80
- | 9-10 | Every outline item traces to the title's promise, first value in the first minute, nothing unrelated, at least one item that beats the expectation, and each section runs a full story loop that reopens before it closes |
81
- | 7-8 | Every item traces to the promise, value arrives late |
82
+ | 9-10 | Every outline item traces to the title's promise, first value in the first minute, nothing unrelated, at least one item that beats the expectation, each section runs a full story loop that reopens before it closes, every beat has an `[ON SCREEN]` note, and every section has a name of 5 words or fewer |
83
+ | 7-8 | Every item traces to the promise, value arrives late, or some beats have nothing on screen |
82
84
  | 4-6 | Some sections serve the creator, not the promise |
83
85
  | 1-3 | An info dump of everything the creator knows |
84
86
 
@@ -111,9 +113,16 @@ subscribe, and buy, and YouTube shows the video to more people like them.
111
113
  Signal to stage map:
112
114
  - Low CTR: inspect packaging (stage 2). Low CTR alone is not proof. Hits have
113
115
  run at 2 to 3%.
114
- - Early retention drop (first 30 seconds): inspect the hook (stage 3).
116
+ - Early retention drop (first 30 seconds): inspect the hook (stage 3). From
117
+ `retention.py`, a hook leak under 25 points is healthy, under 40 is leaking,
118
+ and 40 or more is severe. Fix the first lines of the script, not the edit.
115
119
  - A cliff later: inspect that moment in the body (stage 4), for example a hard
116
- call to action. Trim it after publishing if needed.
120
+ call to action, a sponsor read, or a topic change with no signpost. Trim it
121
+ after publishing if needed.
122
+ - A steady slide across the middle: pacing. Fix it by cutting, not rewriting.
123
+ - Hook leak healthy and the slide flat, but views low: the video is fine, so
124
+ inspect packaging (stage 2).
125
+ - Comments full of the same question: that question is the next video's idea.
117
126
  - Views flat but comments glowing: inspect the idea (stage 1). The ceiling may be low.
118
127
  - Many negative or "this isn't what the title said" comments: the promise and
119
128
  the body do not match (stages 2 and 4).
@@ -3,10 +3,15 @@
3
3
 
4
4
  Usage:
5
5
  floor_ceiling.py '<topic>' [--limit 30] [--json]
6
+ floor_ceiling.py --channel <url> [--channel <url> ...] [--limit 30] [--min 2.0] [--json]
6
7
  floor_ceiling.py --self-test
7
8
 
8
9
  Floor = median views. Ceiling = p90 and max views. Fresh = share uploaded in the
9
10
  last 12 months. Entries with no view count or date are counted, never guessed.
11
+
12
+ --channel ranks each channel's recent uploads by their multiple over that
13
+ channel's own median, so a small channel's hit counts as much as a big one's.
14
+ Channels with under 4 videos that have view counts are skipped, not ranked.
10
15
  Stdlib only. Calls yt-dlp as an external tool, with no shell.
11
16
  """
12
17
  import argparse
@@ -19,6 +24,7 @@ import subprocess
19
24
  import sys
20
25
 
21
26
  FRESH_DAYS = 365
27
+ MIN_VIDEOS = 4
22
28
 
23
29
 
24
30
  def percentile(sorted_vals, p):
@@ -70,6 +76,52 @@ def search(topic, limit):
70
76
  return entries
71
77
 
72
78
 
79
+ def channel_videos(url, limit):
80
+ if not shutil.which("yt-dlp"):
81
+ sys.exit("yt-dlp not found. Install it, or ask the user for view counts.")
82
+ url = url.rstrip("/")
83
+ if not url.endswith("/videos"):
84
+ url += "/videos"
85
+ cmd = ["yt-dlp", "--flat-playlist", "-J", "--no-warnings", "--ignore-errors",
86
+ "--playlist-end", str(limit), url]
87
+ out = subprocess.run(cmd, capture_output=True, text=True, timeout=300).stdout
88
+ try:
89
+ d = json.loads(out)
90
+ except json.JSONDecodeError:
91
+ return url, []
92
+ name = d.get("channel") or d.get("uploader") or d.get("title") or url
93
+ return name, [{k: e.get(k) for k in ("view_count", "title", "url")} for e in d.get("entries") or []]
94
+
95
+
96
+ def outliers(channels, min_multiple):
97
+ """channels: {name: [entries]}. Returns (rows sorted by multiple, skipped)."""
98
+ rows, skipped = [], []
99
+ for name, entries in channels.items():
100
+ counted = [e for e in entries if isinstance(e.get("view_count"), int)]
101
+ if len(counted) < MIN_VIDEOS:
102
+ skipped.append({"channel": name, "videos": len(counted)})
103
+ continue
104
+ med = statistics.median(e["view_count"] for e in counted)
105
+ for e in counted:
106
+ m = e["view_count"] / med if med else 0
107
+ if m >= min_multiple:
108
+ rows.append({"channel": name, "median": int(med), "views": e["view_count"],
109
+ "multiple": round(m, 2), "title": e.get("title"), "url": e.get("url")})
110
+ rows.sort(key=lambda r: -r["multiple"])
111
+ return rows, skipped
112
+
113
+
114
+ def report_outliers(rows, skipped, min_multiple):
115
+ print(f"Outliers at {min_multiple}x their own channel median or more:")
116
+ for r in rows[:25]:
117
+ print(f" {r['multiple']:>6.2f}x {r['views']:>12,} vs median {r['median']:>10,} "
118
+ f"{(r['channel'] or '')[:20]:<20} {(r['title'] or '')[:60]}")
119
+ if not rows:
120
+ print(" none. Lower --min or raise --limit.")
121
+ for s in skipped:
122
+ print(f"Skipped {s['channel']}: {s['videos']} videos with views, need {MIN_VIDEOS} for a median.")
123
+
124
+
73
125
  def fmt(n):
74
126
  return "unknown" if n is None else f"{n:,}"
75
127
 
@@ -106,21 +158,39 @@ def self_test():
106
158
  assert s["top"][0]["views"] == 300000
107
159
  empty = summarize([], today)
108
160
  assert empty["floor_median"] is None and empty["fresh_share"] is None
161
+ chans = {
162
+ "small": [{"view_count": v, "title": f"s{v}"} for v in (1000, 1200, 800, 9000)],
163
+ "big": [{"view_count": v, "title": f"b{v}"} for v in (500000, 400000, 600000, 700000)],
164
+ "thin": [{"view_count": 50, "title": "t"}, {"view_count": None, "title": "u"}],
165
+ }
166
+ rows, skipped = outliers(chans, 2.0)
167
+ assert [r["title"] for r in rows] == ["s9000"], rows # 9000 / median 1100 = 8.18x
168
+ assert rows[0]["multiple"] == 8.18 and rows[0]["median"] == 1100
169
+ assert skipped == [{"channel": "thin", "videos": 1}], skipped
109
170
  print("self-test ok")
110
171
 
111
172
 
112
173
  def main():
113
174
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
114
175
  ap.add_argument("topic", nargs="?")
176
+ ap.add_argument("--channel", action="append", default=[])
177
+ ap.add_argument("--min", type=float, default=2.0)
115
178
  ap.add_argument("--limit", type=int, default=30)
116
179
  ap.add_argument("--json", action="store_true")
117
180
  ap.add_argument("--self-test", action="store_true")
118
181
  a = ap.parse_args()
119
182
  if a.self_test:
120
183
  return self_test()
184
+ a.limit = max(1, min(a.limit, 100))
185
+ if a.channel:
186
+ rows, skipped = outliers(dict(channel_videos(u, a.limit) for u in a.channel), a.min)
187
+ if a.json:
188
+ print(json.dumps({"outliers": rows, "skipped": skipped}, indent=2))
189
+ else:
190
+ report_outliers(rows, skipped, a.min)
191
+ return
121
192
  if not a.topic:
122
193
  ap.error("topic is required")
123
- a.limit = max(1, min(a.limit, 100))
124
194
  s = summarize(search(a.topic, a.limit), dt.date.today())
125
195
  if a.json:
126
196
  print(json.dumps({"topic": a.topic, **s}, indent=2))
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env python3
2
+ """Lint a YouTube title and thumbnail text as one pairing.
3
+
4
+ Usage:
5
+ pairing.py --title '<title>' [--thumb '<thumbnail words>'] [--json]
6
+ pairing.py <file> [--json] one 'title | thumb' per line, ranked
7
+ pairing.py --self-test
8
+
9
+ Checks: desktop search cuts near 60 characters, a mobile feed near 40, and the
10
+ hard limit is 100. The thumbnail must not repeat a title word and should carry
11
+ 3 words or fewer. More than 2 all-caps words reads as spam. Vague adjectives
12
+ lose to a number, a name, or a date. The first 3 words must carry the subject.
13
+ Score is 0-100: a heuristic for comparing options, not a CTR prediction.
14
+ """
15
+ import argparse
16
+ import json
17
+ import re
18
+
19
+ DESKTOP, MOBILE, HARD = 60, 40, 100
20
+ VAGUE = {"amazing", "awesome", "best", "complete", "crazy", "epic", "everything",
21
+ "huge", "incredible", "insane", "massive", "perfect", "powerful",
22
+ "revolutionary", "secret", "ultimate", "unbelievable"}
23
+ FILLER = {"a", "an", "and", "are", "for", "how", "i", "in", "is", "it", "my", "of",
24
+ "on", "or", "that", "the", "this", "to", "what", "why", "with", "you", "your"}
25
+
26
+
27
+ def words(text):
28
+ return re.findall(r"[a-z0-9']+", text.lower())
29
+
30
+
31
+ def has_specific(title):
32
+ # a digit, or a capitalised word after the first (a name, a product, a month)
33
+ return bool(re.search(r"\d", title)) or any(w[:1].isupper() and not w.isupper()
34
+ for w in title.split()[1:])
35
+
36
+
37
+ def lint(title, thumb=None):
38
+ t = title.strip()
39
+ n = len(t)
40
+ issues = []
41
+ if n > HARD:
42
+ issues.append(("length", f"{n} chars, over YouTube's {HARD} limit"))
43
+ elif n > DESKTOP:
44
+ issues.append(("length", f"{n} chars, desktop search cuts near {DESKTOP}"))
45
+ if n > MOBILE:
46
+ shown = t[:MOBILE].rsplit(" ", 1)[0]
47
+ issues.append(("mobile", f'mobile feed shows about "{shown}...": does the subject survive?'))
48
+ caps = [w for w in t.split() if len(w) > 2 and w.isupper()]
49
+ if len(caps) > 2:
50
+ issues.append(("caps", f"{len(caps)} all-caps words, keep it to 2"))
51
+ vague = sorted({w for w in words(t) if w in VAGUE})
52
+ if vague:
53
+ issues.append(("vague", f"{', '.join(vague)}: swap for a number, a name, or a date"))
54
+ if not has_specific(t):
55
+ issues.append(("specific", "no number, name, or date"))
56
+ if words(t) and all(w in FILLER for w in words(t)[:3]):
57
+ issues.append(("front", "first 3 words are filler, move the subject forward"))
58
+ if thumb:
59
+ shared = sorted((set(words(t)) - FILLER) & (set(words(thumb)) - FILLER))
60
+ if shared:
61
+ issues.append(("repeat", f"thumbnail repeats {', '.join(shared)}: say what the title does not"))
62
+ if len(words(thumb)) > 3:
63
+ issues.append(("thumb", f"{len(words(thumb))} thumbnail words, 3 is the most that reads at feed size"))
64
+ # ponytail: flat 15 points per issue, weight by kind if scores start tying
65
+ return {"title": t, "thumb": thumb, "chars": n,
66
+ "score": max(0, 100 - 15 * len(issues)), "issues": issues}
67
+
68
+
69
+ def show(r):
70
+ pair = f'"{r["title"]}"' + (f' + [{r["thumb"]}]' if r["thumb"] else "")
71
+ print(f"{r['score']:>3}/100 {pair} ({r['chars']} chars)")
72
+ for kind, msg in r["issues"]:
73
+ print(f" x {kind:<9} {msg}")
74
+
75
+
76
+ def self_test():
77
+ long = lint("This is a really long title that goes on and on past sixty characters")
78
+ assert any(k == "length" for k, _ in long["issues"]), long
79
+ rep = lint("I Tried 7 Cold Email Tools for 30 Days", "COLD EMAIL WINNER")
80
+ assert any(k == "repeat" for k, _ in rep["issues"]), rep
81
+ clean = lint("I Tried 7 Cold Email Tools for 30 Days", "ONE WON")
82
+ assert clean["issues"] == [] and clean["score"] == 100, clean
83
+ vague = lint("the ultimate guide to growth")
84
+ kinds = {k for k, _ in vague["issues"]}
85
+ assert {"vague", "specific"} <= kinds, vague
86
+ assert any(k == "front" for k, _ in lint("how is it done in 2026")["issues"])
87
+ assert lint("STOP DOING THIS NOW Please")["issues"][0][0] == "caps"
88
+ print("self-test ok")
89
+
90
+
91
+ def main():
92
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
93
+ ap.add_argument("file", nargs="?")
94
+ ap.add_argument("--title")
95
+ ap.add_argument("--thumb")
96
+ ap.add_argument("--json", action="store_true")
97
+ ap.add_argument("--self-test", action="store_true")
98
+ a = ap.parse_args()
99
+ if a.self_test:
100
+ return self_test()
101
+ if a.title:
102
+ rows = [lint(a.title, a.thumb)]
103
+ elif a.file:
104
+ rows = []
105
+ with open(a.file, encoding="utf-8") as fh:
106
+ for line in fh:
107
+ if line.strip():
108
+ title, _, thumb = line.partition("|")
109
+ rows.append(lint(title, thumb.strip() or None))
110
+ else:
111
+ ap.error("give --title or a file")
112
+ rows.sort(key=lambda r: -r["score"])
113
+ if a.json:
114
+ print(json.dumps(rows, indent=2))
115
+ else:
116
+ for r in rows:
117
+ show(r)
118
+
119
+
120
+ if __name__ == "__main__":
121
+ main()
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env python3
2
+ """Read a YouTube Studio audience-retention export and find where viewers leave.
3
+
4
+ Usage:
5
+ retention.py <retention.csv> [--duration <seconds>] [--transcript <file.srt|.vtt>] [--json]
6
+ retention.py --self-test
7
+
8
+ Export: Studio > the video > Analytics > Engagement > audience retention chart >
9
+ download > "Audience retention". Each row is a position (seconds, or percent of
10
+ the video) and the percent still watching. Pass --duration when the position is
11
+ a percent, so the opening and the cliffs come out in seconds.
12
+
13
+ Three readings, three different fixes:
14
+ Hook leak share lost in the first 30 seconds. Under 25 healthy, under 40 leaking, else severe.
15
+ Cliffs the 5 steepest drops (points per second): a moment to look at.
16
+ Slide average loss per second across the middle: pacing, fixed by cutting.
17
+ Stdlib only. Reads local files, never the network.
18
+ """
19
+ import argparse
20
+ import csv
21
+ import json
22
+ import re
23
+
24
+ HOOK_SECONDS = 30
25
+ CLIFF_MIN = 0.8 # ponytail: fixed drop floor in points, scale by video length if short videos over-report
26
+
27
+
28
+ def load_rows(path):
29
+ rows = []
30
+ with open(path, newline="", encoding="utf-8-sig", errors="replace") as fh:
31
+ for cells in csv.reader(fh):
32
+ nums = []
33
+ for c in cells:
34
+ try:
35
+ nums.append(float(c.strip().replace("%", "").replace(",", "")))
36
+ except ValueError:
37
+ pass
38
+ if len(nums) >= 2:
39
+ rows.append((nums[0], nums[1]))
40
+ return rows
41
+
42
+
43
+ def to_seconds(rows, duration):
44
+ if duration and max(x for x, _ in rows) <= 100.5:
45
+ return [(x / 100 * duration, y) for x, y in rows]
46
+ return rows
47
+
48
+
49
+ def load_cues(path):
50
+ text = open(path, encoding="utf-8-sig", errors="replace").read()
51
+ stamp = r"(\d+):(\d\d):(\d\d)[,.](\d+)"
52
+ cues = []
53
+ for block in re.split(r"\n\s*\n", text):
54
+ m = re.search(stamp + r"\s*-->\s*" + stamp, block)
55
+ if not m:
56
+ continue
57
+ g = [int(v) for v in m.groups()]
58
+ start = g[0] * 3600 + g[1] * 60 + g[2] + g[3] / 1000
59
+ end = g[4] * 3600 + g[5] * 60 + g[6] + g[7] / 1000
60
+ words = " ".join(line for line in block[m.end():].splitlines() if line.strip())
61
+ cues.append((start, end, re.sub(r"<[^>]+>", "", words)))
62
+ return cues
63
+
64
+
65
+ def analyze(rows, cues=None):
66
+ start = rows[0][1]
67
+ opening = [y for x, y in rows if x <= HOOK_SECONDS]
68
+ hook_leak = start - min(opening) if opening else 0.0
69
+ band = "healthy" if hook_leak < 25 else "leaking" if hook_leak < 40 else "severe"
70
+ # steepest first: points lost per second, so a sharp drop beats a long gentle one
71
+ drops = [((rows[i - 1][1] - rows[i][1]) / ((rows[i][0] - rows[i - 1][0]) or 1),
72
+ rows[i - 1][1] - rows[i][1], rows[i - 1][0], rows[i][0]) for i in range(1, len(rows))]
73
+ cliffs = []
74
+ for _, lost, x0, x1 in sorted(drops, reverse=True):
75
+ if len(cliffs) == 5:
76
+ break
77
+ if lost < CLIFF_MIN:
78
+ continue
79
+ said = " ".join(c[2] for c in cues or [] if c[0] <= x0 + 4 and c[1] >= x0 - 4)
80
+ cliffs.append({"at": round(x0, 1), "to": round(x1, 1), "lost": round(lost, 2), "said": said[:160]})
81
+ middle = [r for r in rows if r[0] > HOOK_SECONDS]
82
+ span = middle[-1][0] - middle[0][0] if len(middle) > 1 else 0
83
+ slide = (middle[0][1] - middle[-1][1]) / span if span else 0.0
84
+ return {"start": start, "end": rows[-1][1], "hook_leak": round(hook_leak, 2), "hook_band": band,
85
+ "cliffs": cliffs, "slide_per_second": round(slide, 4)}
86
+
87
+
88
+ def report(r):
89
+ print(f"Retention {r['start']:.1f}% -> {r['end']:.1f}%")
90
+ print(f"Hook leak: {r['hook_leak']:.1f} points lost in the first {HOOK_SECONDS}s [{r['hook_band']}]")
91
+ print("Cliffs:")
92
+ for c in r["cliffs"]:
93
+ said = f' "{c["said"]}"' if c["said"] else ""
94
+ print(f" -{c['lost']:.1f} at {c['at']:.0f}{said}")
95
+ if not r["cliffs"]:
96
+ print(f" none over {CLIFF_MIN} points: the loss is slide, not moments")
97
+ print(f"Slide: {r['slide_per_second']:.3f} points per second across the middle")
98
+
99
+
100
+ def self_test():
101
+ # 300s video in percent positions; a 6-point cliff at 15% (45s)
102
+ rows = [(0, 100), (5, 88), (10, 82), (15, 80), (20, 74), (40, 70), (60, 66), (80, 62), (100, 58)]
103
+ rows = to_seconds(rows, 300)
104
+ assert rows[3] == (45.0, 80)
105
+ cues = [(40.0, 46.0, "quick word from our sponsor"), (100.0, 104.0, "later")]
106
+ r = analyze(rows, cues)
107
+ assert r["hook_leak"] == 18 and r["hook_band"] == "healthy", r
108
+ assert r["cliffs"][0]["lost"] == 12 and r["cliffs"][0]["at"] == 0.0, r # opening drop is steepest
109
+ cliff45 = next(c for c in r["cliffs"] if c["at"] == 45.0)
110
+ assert cliff45["lost"] == 6 and "sponsor" in cliff45["said"], r
111
+ assert r["slide_per_second"] == round((80 - 58) / 255, 4), r # middle starts at 45s
112
+ assert analyze([(0, 100), (10, 55), (60, 50)])["hook_band"] == "severe"
113
+ print("self-test ok")
114
+
115
+
116
+ def main():
117
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
118
+ ap.add_argument("csv", nargs="?")
119
+ ap.add_argument("--duration", type=float)
120
+ ap.add_argument("--transcript")
121
+ ap.add_argument("--json", action="store_true")
122
+ ap.add_argument("--self-test", action="store_true")
123
+ a = ap.parse_args()
124
+ if a.self_test:
125
+ return self_test()
126
+ if not a.csv:
127
+ ap.error("csv is required")
128
+ rows = load_rows(a.csv)
129
+ if len(rows) < 8:
130
+ ap.error("need at least 8 data rows from the export")
131
+ rows = to_seconds(rows, a.duration)
132
+ if max(x for x, _ in rows) <= 100.5 and not a.duration:
133
+ print("Note: positions look like percents. Pass --duration for seconds; the hook window is approximate.")
134
+ r = analyze(rows, load_cues(a.transcript) if a.transcript else None)
135
+ if a.json:
136
+ print(json.dumps(r, indent=2))
137
+ else:
138
+ report(r)
139
+
140
+
141
+ if __name__ == "__main__":
142
+ main()