← Back to skills

Domain skill

youtube

Markdown synced from browser-harness domain skills.

Host
youtube
Files
1

Agent prompt

Use this skill

Copy this prompt into your coding agent to make it enable browser-harness domain skills and read this exact domain folder before automating.

Set up https://github.com/browser-use/browser-harness for me if it is not already installed. If setup is needed, read `install.md` first to install and connect it to my real browser. Then read `SKILL.md` for normal usage and always read `helpers.py` because that is where the browser-harness functions are.

Enable domain skills if they are not already enabled by setting `BH_DOMAIN_SKILLS=1` for browser-harness. Use the `youtube` domain skill from `agent-workspace/domain-skills/youtube/`. Read every markdown file for this domain before inventing an approach:
- agent-workspace/domain-skills/youtube/scraping.md

Use those domain-skill notes to complete my task for `youtube` in my real browser. When you open a setup, verification, or task tab, activate it so I can see the active browser tab.

Skill contents

What the agent will read

Data Extraction

scraping.md

Source
  • Field-tested against youtube.com on 2026-04-21. No authentication required for any approach documented here.
  • ---
  • https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v={VIDEOID}&format=json
  • Returns JSON in 0.3s. Works for any public video. Does not require login.
Show full markdown

Field-tested against youtube.com on 2026-04-21. No authentication required for any approach documented here.


Approach 1 (Fastest): oEmbed API — No Auth, No Browser

https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v={VIDEO_ID}&format=json

Returns JSON in ~0.3s. Works for any public video. Does not require login.

python
from helpers import http_get
import json

def youtube_oembed(video_id):
    """Fetch oEmbed metadata for a YouTube video.

    Returns title, author, thumbnail URL, and embed iframe HTML.
    """
    url = f"https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v={video_id}&format=json"
    return json.loads(http_get(url))

data = youtube_oembed("dQw4w9WgXcQ")
# {
#   "title":            "Rick Astley - Never Gonna Give You Up (Official Video) (4K Remaster)",
#   "author_name":      "Rick Astley",
#   "author_url":       "https://www.youtube.com/@RickAstleyYT",
#   "type":             "video",
#   "thumbnail_url":    "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg",
#   "thumbnail_width":  480,
#   "thumbnail_height": 360,
#   "width":            200,
#   "height":           113,
#   "version":          "1.0",
#   "provider_name":    "YouTube",
#   "html":             '<iframe width="200" height="113" src="https://www.youtube.com/embed/dQw4w9WgXcQ?feature=oembed" ...>'
# }

Bulk oEmbed (ThreadPoolExecutor)

python
from concurrent.futures import ThreadPoolExecutor
import json
from helpers import http_get

video_ids = ["dQw4w9WgXcQ", "jNQXAC9IVRw", "9bZkp7q19f0"]

def fetch_oembed(vid):
    url = f"https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v={vid}&format=json"
    try:
        return json.loads(http_get(url))
    except Exception as e:
        return {"error": str(e), "id": vid}

with ThreadPoolExecutor(max_workers=5) as ex:
    results = list(ex.map(fetch_oembed, video_ids))
# 3 videos: ~4.1s total (YouTube oEmbed is slower than Spotify's; don't use >5 workers)

Approach 2: Watch Page — Full Metadata via ytInitialPlayerResponse

Every youtube.com/watch?v={ID} page embeds two JSON blobs in the HTML:

  • ytInitialPlayerResponse — video details, microformat, caption track list
  • ytInitialData — comments section structure, related videos

Extract all video metadata

python
from helpers import http_get
import json, re

def scrape_video(video_id):
    html = http_get(f"https://www.youtube.com/watch?v={video_id}")

    # ---- ytInitialPlayerResponse ----
    m = re.search(r'var ytInitialPlayerResponse = (\{.*?\});(?:var|</script>)', html, re.DOTALL)
    if not m:
        raise ValueError(f"ytInitialPlayerResponse not found for video {video_id} — video may be private, deleted, or region-blocked")
    pr = json.loads(m.group(1))

    # Check playability before parsing
    status = pr.get("playabilityStatus", {}).get("status")
    if status == "LOGIN_REQUIRED":
        raise ValueError(f"Video {video_id} is age-restricted or login-gated (playabilityStatus: LOGIN_REQUIRED)")
    if status == "ERROR":
        reason = pr.get("playabilityStatus", {}).get("reason", "unknown")
        raise ValueError(f"Video {video_id} is unavailable: {reason}")

    vd  = pr["videoDetails"]
    mf  = pr["microformat"]["playerMicroformatRenderer"]
    caps = pr.get("captions", {}) \
             .get("playerCaptionsTracklistRenderer", {}) \
             .get("captionTracks", [])

    return {
        # Core
        "video_id":      vd["videoId"],
        "title":         vd["title"],
        "author":        vd["author"],
        "channel_id":    vd["channelId"],
        "description":   vd["shortDescription"],
        "duration_s":    int(vd["lengthSeconds"]),
        "view_count":    int(vd["viewCount"]),
        "keywords":      vd.get("keywords", []),
        "is_live":       vd.get("isLiveContent", False),
        "is_private":    vd.get("isPrivate", False),
        # Microformat (richer publishing data)
        "publish_date":  mf.get("publishDate"),   # ISO 8601, e.g. "2009-10-25T00:57:33-07:00"
        "upload_date":   mf.get("uploadDate"),
        "category":      mf.get("category"),       # e.g. "Music", "Gaming", "Education"
        "like_count":    int(mf.get("likeCount", 0)),
        "is_family_safe": mf.get("isFamilySafe"),
        "is_unlisted":   mf.get("isUnlisted"),
        "available_countries": mf.get("availableCountries", []),  # list of ISO 3166-1 alpha-2 codes
        "channel_name":  mf.get("ownerChannelName"),
        "channel_url":   mf.get("ownerProfileUrl"),
        "embed_url":     mf.get("embed", {}).get("iframeUrl"),
        # Thumbnails (all publicly accessible, no auth)
        "thumbnail_hq":  f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg",    # 480×360, always exists
        "thumbnail_max": f"https://i.ytimg.com/vi/{video_id}/maxresdefault.jpg", # 1280×720, may 404
        # Caption tracks — baseUrl is included for reference but returns empty in practice;
        # use the Show Transcript UI flow in the browser instead (see playback.md)
        "caption_tracks": [
            {
                "lang":     t.get("languageCode"),
                "name":     t.get("name", {}).get("simpleText"),
                "kind":     t.get("kind", "manual"),  # "manual" or "asr" (auto-generated)
                "base_url": t.get("baseUrl"),
            }
            for t in caps
        ],
    }

video = scrape_video("dQw4w9WgXcQ")
# {
#   "video_id":     "dQw4w9WgXcQ",
#   "title":        "Rick Astley - Never Gonna Give You Up (Official Video) (4K Remaster)",
#   "author":       "Rick Astley",
#   "channel_id":   "UCuAXFkgsw1L7xaCfnd5JJOw",
#   "duration_s":   213,
#   "view_count":   1764468859,
#   "publish_date": "2009-10-24T23:57:33-07:00",
#   "category":     "Music",
#   "like_count":   16942558,
#   "caption_tracks": [
#     {"lang": "en",    "name": "English",                "kind": "manual"},
#     {"lang": "en",    "name": "English (auto-generated)", "kind": "asr"},
#     {"lang": "de-DE", "name": "German (Germany)",        "kind": "manual"},
#     ...
#   ],
# }

Approach 3: Search Results — No Auth

youtube.com/results?search_query={QUERY} is server-side rendered. The ytInitialData blob contains up to ~20 video results.

python
from helpers import http_get
import json, re
from urllib.parse import quote_plus

def youtube_search(query, max_results=20):
    """Search YouTube videos without a browser or API key."""
    url = f"https://www.youtube.com/results?search_query={quote_plus(query)}"
    html = http_get(url)

    m = re.search(r'var ytInitialData = (\{.*?\});</script>', html, re.DOTALL)
    data = json.loads(m.group(1))

    # Walk the nested structure to find videoRenderer items
    section_contents = (
        data.get("contents", {})
            .get("twoColumnSearchResultsRenderer", {})
            .get("primaryContents", {})
            .get("sectionListRenderer", {})
            .get("contents", [])
    )

    results = []
    for section in section_contents:
        for item in section.get("itemSectionRenderer", {}).get("contents", []):
            vr = item.get("videoRenderer", {})
            if not vr:
                continue
            snippet = vr.get("detailedMetadataSnippets", [])
            desc = (
                "".join(r.get("text", "") for r in snippet[0]["snippetText"]["runs"])
                if snippet else None
            )
            results.append({
                "video_id":   vr["videoId"],
                "url":        f"https://www.youtube.com/watch?v={vr['videoId']}",
                "title":      vr.get("title", {}).get("runs", [{}])[0].get("text"),
                "channel":    vr.get("ownerText", {}).get("runs", [{}])[0].get("text"),
                "channel_url": (
                    "https://www.youtube.com"
                    + vr.get("ownerText", {}).get("runs", [{}])[0]
                                              .get("navigationEndpoint", {})
                                              .get("browseEndpoint", {})
                                              .get("canonicalBaseUrl", "")
                ),
                "duration":   vr.get("lengthText", {}).get("simpleText"),  # e.g. "3:32"
                "views":      vr.get("viewCountText", {}).get("simpleText"),  # e.g. "1,764,468,859 views"
                "published":  vr.get("publishedTimeText", {}).get("simpleText"),  # e.g. "7 years ago"
                "description_snippet": desc,
                "thumbnail":  f"https://i.ytimg.com/vi/{vr['videoId']}/hqdefault.jpg",
            })
            if len(results) >= max_results:
                return results  # exit both loops immediately
    return results

results = youtube_search("python tutorial", max_results=5)
# Returns up to ~14-20 results (YouTube serves fewer than 20 on first page)
# [
#   {
#     "video_id":  "K5KVEU3aaeQ",
#     "title":     "Python Full Course for Beginners",
#     "channel":   "Programming with Mosh",
#     "duration":  "2:02:21",
#     "views":     "6,056,121 views",
#     "published": "1 year ago",
#   }, ...
# ]

Approach 4: Channel Metadata — No Auth

Channel pages (youtube.com/@handle or youtube.com/channel/{CHANNEL_ID}) embed metadata in ytInitialData.

python
from helpers import http_get
import json, re

def scrape_channel(handle_or_id):
    """
    handle_or_id: "@RickAstleyYT"           (handle, with @)
                  "UCuAXFkgsw1L7xaCfnd5JJOw" (channel ID)
    """
    if handle_or_id.startswith("UC"):
        url = f"https://www.youtube.com/channel/{handle_or_id}"
    else:
        url = f"https://www.youtube.com/{handle_or_id}"

    html = http_get(url)
    m = re.search(r'var ytInitialData = (\{.*?\});</script>', html, re.DOTALL)
    data = json.loads(m.group(1))

    # Canonical metadata (always present)
    cmd = data.get("metadata", {}).get("channelMetadataRenderer", {})

    # Subscriber count + handle from pageHeaderViewModel
    ph = (
        data.get("header", {})
            .get("pageHeaderRenderer", {})
            .get("content", {})
            .get("pageHeaderViewModel", {})
    )
    meta_parts = [
        part.get("text", {}).get("content", "")
        for row in ph.get("metadata", {})
                     .get("contentMetadataViewModel", {})
                     .get("metadataRows", [])
        for part in row.get("metadataParts", [])
    ]
    # meta_parts is typically: ["@handle", "4.48m subscribers", "N videos"]

    # Avatar (take the largest source)
    avatar_sources = (
        ph.get("image", {})
          .get("decoratedAvatarViewModel", {})
          .get("avatar", {})
          .get("avatarViewModel", {})
          .get("image", {})
          .get("sources", [])
    )
    avatar_url = avatar_sources[-1]["url"] if avatar_sources else None

    # Channel banner
    banner_sources = (
        ph.get("banner", {})
          .get("imageBannerViewModel", {})
          .get("image", {})
          .get("sources", [])
    )
    banner_url = banner_sources[-1]["url"] if banner_sources else None

    return {
        "channel_id":  cmd.get("externalId"),
        "title":       cmd.get("title"),
        "description": cmd.get("description"),
        "channel_url": cmd.get("channelUrl"),
        "keywords":    cmd.get("keywords", ""),
        "handle":      meta_parts[0] if len(meta_parts) > 0 else None,
        "subscribers": meta_parts[1] if len(meta_parts) > 1 else None,  # e.g. "4.48m subscribers"
        "avatar_url":  avatar_url,
        "banner_url":  banner_url,
    }

channel = scrape_channel("@RickAstleyYT")
# {
#   "channel_id":  "UCuAXFkgsw1L7xaCfnd5JJOw",
#   "title":       "Rick Astley",
#   "description": "2026 UK & Ireland Reflection Tour...",
#   "channel_url": "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw",
#   "handle":      "@RickAstleyYT",
#   "subscribers": "4.48m subscribers",
#   "avatar_url":  "https://yt3.googleusercontent.com/...",
#   "banner_url":  "https://yt3.googleusercontent.com/...",
# }

Thumbnail URLs — All Sizes

All thumbnail sizes are publicly accessible without auth. Construct directly from video_id:

python
def thumbnail_urls(video_id):
    base = f"https://i.ytimg.com/vi/{video_id}"
    return {
        "default":  f"{base}/default.jpg",      # 120×90,  always exists
        "medium":   f"{base}/mqdefault.jpg",     # 320×180, always exists
        "high":     f"{base}/hqdefault.jpg",     # 480×360, always exists
        "standard": f"{base}/sddefault.jpg",     # 640×480, always exists
        "maxres":   f"{base}/maxresdefault.jpg", # 1280×720, may 404 on older/low-res videos
    }

Extract Video ID from Any URL

python
import re

def extract_video_id(url):
    """Extract YouTube video ID (11-char) from any YouTube URL format."""
    m = re.search(r'(?:v=|/v/|youtu\.be/|/embed/|/shorts/)([A-Za-z0-9_-]{11})', url)
    return m.group(1) if m else None

extract_video_id("https://www.youtube.com/watch?v=dQw4w9WgXcQ")  # "dQw4w9WgXcQ"
extract_video_id("https://youtu.be/dQw4w9WgXcQ")                  # "dQw4w9WgXcQ"
extract_video_id("https://www.youtube.com/shorts/dQw4w9WgXcQ")    # "dQw4w9WgXcQ"
extract_video_id("https://www.youtube.com/embed/dQw4w9WgXcQ")     # "dQw4w9WgXcQ"

What Requires a Browser

The following are not accessible via http_get and require the CDP browser (see playback.md):

  • Trending / Explore (/feed/trending) — ytInitialData loads but video items are empty without cookies
  • Playlist contentsytInitialData returns only microformat; full video list requires session cookies
  • Comments — loaded lazily via XHR, not present in initial HTML
  • Shorts feed — requires JS hydration
  • Channel Videos tab — video list requires cookies for consistent results
  • Caption text contentcaptionTracks[].baseUrl URLs return empty bytes regardless of session state; use the browser's Show Transcript UI flow instead (see playback.md)
  • Age-restricted videos — oEmbed returns HTTP 401; scrape_video() raises ValueError("LOGIN_REQUIRED")

Watch-page DOM hydration — the wait you need

When you do fall through to the browser for watch-page DOM (e.g. because you need a rendered UI state, not just metadata), wait_for_load() is not enough. The load event fires before YouTube's Polymer components hydrate — h1.ytd-watch-metadata yt-formatted-string, ytd-video-owner-renderer #channel-name a, and ytd-watch-info-text all return null for ~2s after load. Add a wait(3) after wait_for_load() before querying any watch-page selector.

Field-tested 2026-04-24 on Brave; same behavior observed on ungoogled-chromium. Prefer the http_get + ytInitialPlayerResponse path above — the browser path exists for flows that need live UI state, not for reading metadata.


URL Patterns

ResourceURL pattern
Videohttps://www.youtube.com/watch?v={VIDEO_ID}
Short URLhttps://youtu.be/{VIDEO_ID}
Shortshttps://www.youtube.com/shorts/{VIDEO_ID}
Channel handlehttps://www.youtube.com/@{HANDLE}
Channel IDhttps://www.youtube.com/channel/{CHANNEL_ID}
Playlisthttps://www.youtube.com/playlist?list={PLAYLIST_ID}
Searchhttps://www.youtube.com/results?search_query={QUERY}
oEmbedhttps://www.youtube.com/oembed?url={VIDEO_URL}&format=json
Thumbnail (HQ)https://i.ytimg.com/vi/{VIDEO_ID}/hqdefault.jpg

Gotchas

  • ytInitialPlayerResponse regex must use non-greedy match with lookahead(\{.*?\});(?:var|</script>) with re.DOTALL is reliable. Do not use \{.*\} (greedy) — it consumes the entire rest of the page.
  • viewCount and lengthSeconds are strings, not intsvd["viewCount"] returns "1764468859". Always cast with int().
  • likeCount lives in microformat, not videoDetailsvideoDetails does not expose like count. microformat.playerMicroformatRenderer.likeCount is a string integer.
  • availableCountries is a list of ISO 3166-1 alpha-2 codes — 249 entries for globally available videos. An empty list means region data is unavailable, not that the video is globally blocked.
  • oEmbed thumbnail is always hqdefault (480×360) — if you need 1280×720, construct the maxresdefault.jpg URL directly, but check for 404 on older videos.
  • Search returns ~14–20 results — YouTube does not guarantee 20 results. Always iterate itemSectionRenderer.contents rather than assuming a fixed count.
  • Channel subscriber count is a rounded string"4.48m subscribers", not an integer. Parse with regex if sorting: re.search(r'([\d.]+)\s*([km]?)', text, re.I).
  • meta_parts order is [handle, subscribers, video_count] — the third element is not always present. Index defensively.
  • Caption baseUrl is not fetchablecaptionTracks[].baseUrl contains expire= and signature= params but returns empty bytes in all tested conditions (plain http_get, XHR from within the page, and fetch() with cookies). Use the Show Transcript UI in the browser for caption text (see playback.md).
  • Age-restricted videosscrape_video() raises ValueError with a LOGIN_REQUIRED message. oEmbed returns HTTP 401 (raises urllib.error.HTTPError). Neither approach can access age-restricted content without login.
  • Private / deleted videos — oEmbed returns HTTP 404 (raises urllib.error.HTTPError). Wrap in try/except.
  • ytInitialData blob terminator is ;</script> — using re.DOTALL with (\{.*?\});</script> is safe; the blob does not contain ;</script> internally.