Scenario
YouTube thumbnails drive 80%+ of click decisions, but you can’t A/B test them natively. This job pulls your video thumbnails (and optionally competitor thumbnails) viavideos.list, extracts the thumbnail URLs, then runs a Focus Group where personas evaluate each thumbnail with the core question: “Which would you click? Rate 1-10.” The result is a click-prediction scorecard with specific visual feedback from each persona — before you commit to a thumbnail.
Architecture
Code
import os, requests, time
YT = os.environ["YOUTUBE_API_KEY"]
MV = os.environ["MAVERA_API_KEY"]
YT_BASE = "https://www.googleapis.com/youtube/v3"
MV_BASE = "https://app.mavera.io/api/v1"
MV_H = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}
VIDEO_IDS = ["VIDEO_ID_1", "VIDEO_ID_2", "VIDEO_ID_3", "VIDEO_ID_4"]
# 1. Fetch video details with thumbnails (1 quota unit)
details = requests.get(f"{YT_BASE}/videos", params={
"key": YT, "id": ",".join(VIDEO_IDS),
"part": "snippet,statistics",
}).json()
if "error" in details:
raise SystemExit(f"YouTube API error: {details['error']['message']}")
thumbnails = []
for video in details.get("items", []):
snippet = video["snippet"]
stats = video.get("statistics", {})
thumb_url = (
snippet.get("thumbnails", {}).get("maxres", {}).get("url")
or snippet.get("thumbnails", {}).get("high", {}).get("url")
or snippet.get("thumbnails", {}).get("medium", {}).get("url", "")
)
thumbnails.append({
"video_id": video["id"],
"title": snippet["title"],
"thumbnail_url": thumb_url,
"views": int(stats.get("viewCount", 0)),
"ctr_context": f"{snippet['channelTitle']} — {snippet['title'][:60]}",
})
print(f"Collected {len(thumbnails)} thumbnails")
# 2. Create Focus Group personas
persona_ids = []
for name, desc in [
("Casual Browser", "Scrolls YouTube homepage on phone. Clicks based on thumbnail emotion and curiosity gap. Ignores text-heavy thumbnails. Prefers faces and bright colors."),
("Search-Intent Viewer", "Types specific queries into YouTube search. Clicks thumbnails that visually confirm the video matches their query. Values clarity over creativity."),
("Subscriber", "Regular subscriber who sees these in their feed. Already trusts the channel. Clicks based on topic interest — thumbnail is secondary but influences priority."),
("Competitor's Audience", "Watches competitor channels. Unfamiliar with this brand. Thumbnail must prove higher quality or unique angle to earn a click away from known creators."),
]:
p = requests.post(f"{MV_BASE}/personas", headers=MV_H, json={
"name": name, "description": desc,
}).json()
persona_ids.append(p["id"])
time.sleep(0.3)
# 3. Build thumbnail descriptions for focus group
thumb_block = "\n\n".join(
f"THUMBNAIL {chr(65+i)}: \"{t['title']}\"\n"
f" Image URL: {t['thumbnail_url']}\n"
f" Channel context: {t['ctr_context']}\n"
f" Current views: {t['views']:,}"
for i, t in enumerate(thumbnails)
)
# 4. Run Focus Group
fg = requests.post(f"{MV_BASE}/focus-groups", headers=MV_H, json={
"name": "YouTube Thumbnail A/B Test",
"persona_ids": persona_ids,
"questions": [
f"You see these thumbnails on YouTube. Which would you click FIRST? Rate each 1-10 for click likelihood.\n\n{thumb_block}",
"What specific visual element in your top-rated thumbnail made you want to click?",
"Which thumbnail would you NEVER click? What turns you off about it?",
"If you could redesign the lowest-rated thumbnail, what would you change? Be specific about colors, text, faces, and composition.",
"Imagine all these thumbnails appeared in a row on the YouTube homepage. Which stands out most and why?",
],
"responses_per_persona": 2,
}).json()
# 5. Poll for results
for _ in range(20):
time.sleep(5)
data = requests.get(f"{MV_BASE}/focus-groups/{fg['id']}", headers=MV_H).json()
if data.get("status") == "completed":
break
# 6. Output
print("\nTHUMBNAIL A/B FOCUS GROUP RESULTS")
print("=" * 60)
for t in thumbnails:
print(f" [{t['video_id'][:11]}] {t['title'][:50]}")
print(f" Thumbnail: {t['thumbnail_url']}")
print(f" Views: {t['views']:,}\n")
print("PERSONA RESPONSES:")
for resp in data.get("responses", []):
persona_name = next(
(name for pid, name in zip(persona_ids, ["Casual Browser", "Search-Intent", "Subscriber", "Competitor's Audience"])
if pid == resp.get("persona_id")),
"Unknown"
)
print(f"\n[{persona_name}] {resp.get('question', '')[:60]}...")
print(f" → {resp.get('answer', '')[:350]}")
const YT = process.env.YOUTUBE_API_KEY;
const MV = process.env.MAVERA_API_KEY;
const YT_BASE = "https://www.googleapis.com/youtube/v3";
const MV_BASE = "https://app.mavera.io/api/v1";
const MV_H = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };
const VIDEO_IDS = ["VIDEO_ID_1", "VIDEO_ID_2", "VIDEO_ID_3", "VIDEO_ID_4"];
// 1. Fetch thumbnails (1 quota unit)
const details = await fetch(
`${YT_BASE}/videos?key=${YT}&id=${VIDEO_IDS.join(",")}&part=snippet,statistics`
).then(r => r.json());
if (details.error) throw new Error(details.error.message);
const thumbnails = (details.items || []).map(v => {
const thumbs = v.snippet.thumbnails || {};
return {
videoId: v.id, title: v.snippet.title,
thumbnailUrl: thumbs.maxres?.url || thumbs.high?.url || thumbs.medium?.url || "",
views: parseInt(v.statistics?.viewCount || "0", 10),
ctrContext: `${v.snippet.channelTitle} — ${v.snippet.title.slice(0, 60)}`,
};
});
console.log(`Collected ${thumbnails.length} thumbnails`);
// 2. Personas
const personaNames = ["Casual Browser", "Search-Intent Viewer", "Subscriber", "Competitor's Audience"];
const personaDescs = [
"Scrolls homepage on phone. Clicks on emotion/curiosity. Ignores text-heavy. Prefers faces, bright colors.",
"Types specific queries. Clicks thumbnails that confirm match. Values clarity over creativity.",
"Regular subscriber, sees in feed. Trusts channel. Clicks on topic interest.",
"Watches competitor channels. Unfamiliar with brand. Needs proof of quality to click.",
];
const personaIds = [];
for (let i = 0; i < personaNames.length; i++) {
const p = await fetch(`${MV_BASE}/personas`, {
method: "POST", headers: MV_H,
body: JSON.stringify({ name: personaNames[i], description: personaDescs[i] }),
}).then(r => r.json());
personaIds.push(p.id);
await new Promise(r => setTimeout(r, 300));
}
// 3. Thumbnail descriptions
const thumbBlock = thumbnails.map((t, i) =>
`THUMBNAIL ${String.fromCharCode(65 + i)}: "${t.title}"\n Image: ${t.thumbnailUrl}\n Context: ${t.ctrContext}\n Views: ${t.views.toLocaleString()}`
).join("\n\n");
// 4. Focus Group
const fg = await fetch(`${MV_BASE}/focus-groups`, {
method: "POST", headers: MV_H,
body: JSON.stringify({
name: "YouTube Thumbnail A/B Test", persona_ids: personaIds,
questions: [
`Rate each thumbnail 1-10 for click likelihood:\n\n${thumbBlock}`,
"What visual element in your top pick made you want to click?",
"Which would you NEVER click? What turns you off?",
"Redesign the lowest-rated: what changes to colors, text, faces, composition?",
"All thumbnails in a row on the homepage — which stands out and why?",
],
responses_per_persona: 2,
}),
}).then(r => r.json());
// 5. Poll
let data;
for (let i = 0; i < 20; i++) {
await new Promise(r => setTimeout(r, 5000));
data = await fetch(`${MV_BASE}/focus-groups/${fg.id}`, { headers: MV_H }).then(r => r.json());
if (data.status === "completed") break;
}
// 6. Output
console.log("\nTHUMBNAIL A/B FOCUS GROUP RESULTS");
console.log("=".repeat(60));
thumbnails.forEach(t => {
console.log(` [${t.videoId.slice(0, 11)}] ${t.title.slice(0, 50)}`);
console.log(` Thumbnail: ${t.thumbnailUrl}\n Views: ${t.views.toLocaleString()}\n`);
});
console.log("PERSONA RESPONSES:");
for (const resp of data.responses || []) {
const idx = personaIds.indexOf(resp.persona_id);
const name = idx >= 0 ? personaNames[idx] : "Unknown";
console.log(`\n[${name}] ${(resp.question || "").slice(0, 60)}...`);
console.log(` → ${(resp.answer || "").slice(0, 350)}`);
}
Example Output
THUMBNAIL A/B FOCUS GROUP RESULTS
============================================================
[dQw4w9WgXc] How to Build a Morning Routine That Sticks
Thumbnail: https://i.ytimg.com/vi/.../maxresdefault.jpg
Views: 245,000
[xYz1234567] 5 Nutrition Myths Debunked by Science
Thumbnail: https://i.ytimg.com/vi/.../maxresdefault.jpg
Views: 89,000
[aBc9876543] I Tried Every Protein Powder — Here's the Winner
Thumbnail: https://i.ytimg.com/vi/.../maxresdefault.jpg
Views: 412,000
[mNoPqRsTuV] Beginner Workout Plan (No Equipment)
Thumbnail: https://i.ytimg.com/vi/.../maxresdefault.jpg
Views: 178,000
PERSONA RESPONSES:
[Casual Browser] Rate each thumbnail 1-10 for click likelihood...
→ A: 6/10 — sunrise photo is calming but doesn't create urgency.
B: 8/10 — red "X" marks on food items create instant curiosity.
C: 9/10 — face with surprised expression + product grid = clickable.
D: 5/10 — generic gym stock photo. Seen this a thousand times.
[Search-Intent Viewer] What visual element made you click?...
→ Thumbnail C: The grid layout showing all products immediately tells
me this video answers my exact query. I don't need to guess what the
video covers — the thumbnail IS the table of contents.
[Competitor's Audience] Which would you NEVER click?...
→ Thumbnail D. It looks identical to every other fitness channel. If I
already watch [Competitor], why would I click a generic thumbnail from
a channel I don't recognize? Give me a reason to switch.
[Subscriber] Redesign the lowest-rated thumbnail...
→ Thumbnail D: Replace stock gym photo with the creator's face doing the
workout. Add text overlay "0 Equipment" in bold yellow. Use a split
frame: left side = living room, right side = results. The current
design says "another workout video" — the redesign says "YOUR workout."
Error Handling
Thumbnail resolution fallback
Thumbnail resolution fallback
Not all videos have
maxres thumbnails (1280×720). The code falls back to high (480×360) then medium (320×180). Custom thumbnails require the channel owner to verify their account.Focus Group with image URLs
Focus Group with image URLs
Mavera Focus Groups analyze thumbnail descriptions and URLs contextually. For pixel-level visual analysis, upload thumbnails as image assets via
POST /assets first and reference the asset IDs.Quota cost
Quota cost
This entire job uses 1 quota unit (
videos.list with up to 50 IDs per call). The most quota-efficient job alongside trending analysis.