luckiest-co 1.0.54 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/youtube-planner/ATTRIBUTION.md +9 -3
- package/skills/youtube-planner/CHANGELOG.md +11 -0
- package/skills/youtube-planner/SKILL.md +89 -23
- package/skills/youtube-planner/references/plan-template.md +9 -5
- package/skills/youtube-planner/references/stages.md +20 -11
- package/skills/youtube-planner/scripts/floor_ceiling.py +71 -1
- package/skills/youtube-planner/scripts/pairing.py +121 -0
- package/skills/youtube-planner/scripts/retention.py +142 -0
package/package.json
CHANGED
|
@@ -14,9 +14,15 @@ our own words. No text or code was copied from the sources below.
|
|
|
14
14
|
loops chained through the video): from Kallaway Marketing's public Facebook
|
|
15
15
|
reel on storytelling loops
|
|
16
16
|
(https://www.facebook.com/reel/4034613460177150, viewed 2026-10-16).
|
|
17
|
-
- Ranking a video by its views over its own channel's median
|
|
18
|
-
title and thumbnail as one pairing
|
|
19
|
-
|
|
17
|
+
- Ranking a video by its views over its own channel's median (skipping channels
|
|
18
|
+
with too few videos), linting the title and thumbnail as one pairing, the
|
|
19
|
+
query test, reading a retention export as hook leak, cliffs, and slide, the
|
|
20
|
+
opening-line rules, on-screen notes per beat, chapter rules and short section
|
|
21
|
+
names, Short candidates with new first lines, comment triage, an audit that
|
|
22
|
+
ends in one fix, and a voice file: ideas from Jakeschincariol/youtube-agent-skill
|
|
23
|
+
(MIT, https://github.com/Jakeschincariol/youtube-agent-skill, reviewed at
|
|
24
|
+
commit a2feb21). `pairing.py`, `retention.py`, and the `--channel` mode are
|
|
25
|
+
our own code.
|
|
20
26
|
- Comments as a satisfaction readout: an idea from
|
|
21
27
|
ScrapeCreators/social-media-research-skills (MIT,
|
|
22
28
|
https://github.com/ScrapeCreators/social-media-research-skills).
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog — luckiest-youtube-planner
|
|
2
2
|
|
|
3
|
+
## 1.2.0 — 2026-10-27
|
|
4
|
+
|
|
5
|
+
- `scripts/pairing.py` lints a title and its thumbnail text as one pairing: desktop and mobile cuts, repeated words, thumbnail word count, all-caps, vague adjectives, nothing specific, filler up front. Packaging now writes 10 titles, shows the top 3, and adds a query test (3 searches whose words the title and first description lines must contain).
|
|
6
|
+
- `floor_ceiling.py --channel` (repeatable) ranks videos by their multiple over their own channel's median and skips channels with fewer than 4 counted videos.
|
|
7
|
+
- `scripts/retention.py` reads a Studio retention export: hook leak in the first 30 seconds with bands, the steepest cliffs with the words spoken there (`--transcript`), and the slide across the middle.
|
|
8
|
+
- Hook: rules for the opening lines (confirm the title, one promise, specific beats big, show the promised thing within 20 seconds, no intro or subscribe ask before 0:45), and 3 versions with the best 2 shown.
|
|
9
|
+
- Body: `[ON SCREEN]` on every beat, section names of 5 words or fewer that become valid chapters, a runtime at 150 wpm, and 3 Short candidates with new first lines.
|
|
10
|
+
- Readout: comment triage into questions, corrections, praise, and bait, a pin suggestion, and a flop report that ends in one change, 3 things to keep, and what not to change yet.
|
|
11
|
+
- Reads `voice.md` before writing spoken lines when the user has one.
|
|
12
|
+
- Ideas from Jakeschincariol/youtube-agent-skill (MIT), in our own words and code.
|
|
13
|
+
|
|
3
14
|
## 1.1.0 — 2026-10-16
|
|
4
15
|
|
|
5
16
|
- Body stage runs each section as a story loop: stakes, open question, turn, and reopen, with the next loop starting before the last one ends. The rubric and per-beat fixes are in stages.md.
|
|
@@ -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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
115
|
-
out at phone size, and what the viewer
|
|
116
|
-
and the thumbnail must say different
|
|
117
|
-
question, and the title closes it just
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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. <
|
|
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
|
-
|
|
|
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:
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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,
|
|
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
|
|
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()
|