Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .claude/skills/youtube-apify-transcript/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## [1.1.3] - 2026-11-02

- Point the Apify fetch at a live transcripts Actor and map timestamped cues onto the skill's start/dur/text shape
- Keep cached pintostudio rows readable; drop stale per-video cost lines

## [1.0.7] - 2026-02-04

- Privacy cleanup: removed hardcoded paths and personal info from docs
8 changes: 3 additions & 5 deletions .claude/skills/youtube-apify-transcript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@ Fetch YouTube video transcripts from anywhere — even cloud servers where YouTu

## 💰 Free Tier

APIFY offers **$5/month free credits** — that's approximately **714 videos per month** at $0.007 each!

No credit card required. [Sign up here](https://apify.com/)
APIFY offers a monthly free-credit allowance. No credit card required. [Sign up here](https://apify.com/)

## 🚀 Quick Start

Expand All @@ -34,9 +32,9 @@ See [SKILL.md](skills/youtube-apify-transcript/SKILL.md) for full documentation,

## 🔗 Links

- [APIFY Free Tier](https://apify.com/pricing) - $5/month free
- [APIFY Free Tier](https://apify.com/pricing)
- [Get API Key](https://console.apify.com/account/integrations)
- [YouTube Transcripts Actor](https://apify.com/karamelo/youtube-transcripts)
- [YouTube Transcripts Actor](https://apify.com/johnvc/YoutubeTranscripts)

## ⚙️ Requirements

Expand Down
23 changes: 9 additions & 14 deletions .claude/skills/youtube-apify-transcript/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: youtube-apify-transcript
description: Fetch YouTube transcripts via APIFY API. Works from cloud IPs (Hetzner, AWS, etc.) by bypassing YouTube's bot detection. Free tier includes $5/month credits (~714 videos). No credit card required.
description: Fetch YouTube transcripts via APIFY API. Works from cloud IPs (Hetzner, AWS, etc.) by bypassing YouTube's bot detection. Uses a live community Actor. No credit card required.
tags: [research]
---

Expand All @@ -14,20 +14,15 @@ YouTube blocks transcript requests from cloud IPs (AWS, GCP, etc.). APIFY runs t

## Free Tier

- **$5/month free credits** (~714 videos)
- Apify platform credits apply
- No credit card required
- Perfect for personal use

## Cost

- **$0.007 per video** (less than 1 cent!)
- Track usage at: https://console.apify.com/billing

## Links

- [APIFY Pricing](https://apify.com/pricing)
- [Get API Key](https://console.apify.com/account/integrations)
- [YouTube Transcript Scraper Actor](https://apify.com/pintostudio/youtube-transcript-scraper)
- [YouTube Transcripts Actor](https://apify.com/johnvc/YoutubeTranscripts)

## Setup

Expand Down Expand Up @@ -73,13 +68,13 @@ python3 scripts/fetch_transcript.py "URL" --lang de

### Caching (saves money!)

Transcripts are cached locally by default. Repeat requests for the same video cost $0.
Transcripts are cached locally by default. Repeat requests for the same video skip a new Actor run.

```bash
# First request: fetches from APIFY ($0.007)
# First request: fetches from APIFY
python3 scripts/fetch_transcript.py "URL"

# Second request: uses cache (FREE!)
# Second request: uses cache
python3 scripts/fetch_transcript.py "URL"
# Output: [cached] Transcript for: VIDEO_ID

Expand Down Expand Up @@ -116,17 +111,17 @@ python3 scripts/fetch_transcript.py --batch urls.txt --json --output all_transcr

## APIFY Actor Input

The script sends the following input to `pintostudio/youtube-transcript-scraper`:
The script sends the following input to the live YouTube transcripts Actor:

```json
{
"videoUrl": "https://www.youtube.com/watch?v=VIDEO_ID"
"youtube_url": "https://www.youtube.com/watch?v=VIDEO_ID"
}
```

**Output fields:**

Each result contains a `data` array of transcript segments:
Each result is normalized to a `segments` array of transcript cues:

| Field | Type | Description |
|---------|--------|------------------------------------|
Expand Down
4 changes: 2 additions & 2 deletions .claude/skills/youtube-apify-transcript/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@openclaw/youtube-apify-transcript",
"version": "1.1.2",
"description": "Fetch YouTube transcripts via APIFY API. Works from cloud IPs (Hetzner, AWS, etc.) by bypassing YouTube's bot detection. Free tier includes $5/month credits (~714 videos).",
"version": "1.1.3",
"description": "Fetch YouTube transcripts via APIFY API. Works from cloud IPs (Hetzner, AWS, etc.) by bypassing YouTube's bot detection. Uses a live community Actor.",
"author": "robbyczgw-cla",
"license": "MIT",
"keywords": [
Expand Down
135 changes: 114 additions & 21 deletions .claude/skills/youtube-apify-transcript/scripts/fetch_transcript.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,10 @@
try:
import requests
except ImportError:
print("Error: 'requests' library not installed.", file=sys.stderr)
sys.exit(1)
requests = None


APIFY_ACTOR_ID = "pintostudio~youtube-transcript-scraper"
APIFY_ACTOR_ID = "johnvc~YoutubeTranscripts"
GOOSEWORKS_API_BASE = os.environ.get("GOOSEWORKS_API_BASE", "https://api.gooseworks.ai")
GOOSEWORKS_API_KEY = os.environ.get("GOOSEWORKS_API_KEY")

Expand All @@ -45,13 +44,113 @@

def get_api_token():
"""Get API token, preferring GooseWorks proxy key."""
if requests is None:
print("Error: 'requests' library not installed.", file=sys.stderr)
sys.exit(1)
token = GOOSEWORKS_API_KEY or os.environ.get("APIFY_API_TOKEN")
if not token:
print("Error: Set GOOSEWORKS_API_KEY or APIFY_API_TOKEN env var.", file=sys.stderr)
sys.exit(1)
return token


def _as_float(value, default=0.0):
"""Coerce a timestamp or duration field to float.

Args:
value: Raw numeric, string, or missing field from an Actor row.
default: Fallback when the value cannot be parsed.

Returns:
A float suitable for the skill's start/dur JSON fields.
"""
try:
return float(value)
except (TypeError, ValueError):
return default


def _normalize_segment(segment):
"""Map one transcript cue onto the skill's start/dur/text shape.

Args:
segment: A string cue or a dict from johnvc timestamped rows
(start/duration) or cached pintostudio rows (start/dur).

Returns:
Dict with start, dur, and text.
"""
if isinstance(segment, str):
return {"start": 0.0, "dur": 0.0, "text": segment}
if not isinstance(segment, dict):
return {"start": 0.0, "dur": 0.0, "text": ""}
duration = segment.get("dur", segment.get("duration", 0))
return {
"start": _as_float(segment.get("start", 0)),
"dur": _as_float(duration),
"text": segment.get("text", "") or "",
}


def build_actor_input(video_url, language=None):
"""Build johnvc/YoutubeTranscripts run input.

Args:
video_url: Canonical YouTube watch URL.
language: Optional ISO 639-1 code from --lang.

Returns:
Actor input dict with youtube_url and optional languages.
"""
payload = {"youtube_url": video_url}
if language:
payload["languages"] = [language]
return payload


def normalize_transcript_result(result):
"""Normalize an Apify dataset item to title/language/segments.

Accepts live johnvc/YoutubeTranscripts rows (timestamped + duration),
cached pintostudio rows (data + dur), and already-normalized cache
files that store segments.

Args:
result: One dataset item or a cached transcript dict.

Returns:
Dict with title, language, and segments (start/dur/text).

Raises:
ValueError: When the Actor row reports a failed fetch.
"""
if not isinstance(result, dict):
return {"title": "Unknown", "language": "unknown", "segments": []}

if result.get("success") is False or result.get("error") is True:
message = result.get("error_message") or "transcript fetch failed"
if isinstance(message, bool):
message = "transcript fetch failed"
raise ValueError(str(message))

if isinstance(result.get("segments"), list):
raw_segments = result["segments"]
elif isinstance(result.get("timestamped"), list):
raw_segments = result["timestamped"]
elif isinstance(result.get("data"), list):
raw_segments = result["data"]
else:
raw_segments = []

language = result.get("language") or result.get("language_code") or "unknown"
title = result.get("title") or "Unknown"
return {
"title": title,
"language": language,
"segments": [_normalize_segment(item) for item in raw_segments],
}


def extract_video_id(url):
"""Extract YouTube video ID from various URL formats."""
# Handle youtu.be short links
Expand Down Expand Up @@ -201,12 +300,7 @@ def run_apify_actor(video_url, api_token, language=None):
# Start the actor run
run_url = f"{APIFY_API_BASE}/acts/{APIFY_ACTOR_ID}/runs"

input_data = {
"videoUrl": video_url
}

if language:
input_data["preferredLanguage"] = language
input_data = build_actor_input(video_url, language)

headers = {
"Content-Type": "application/json"
Expand Down Expand Up @@ -285,14 +379,18 @@ def run_apify_actor(video_url, api_token, language=None):
sys.exit(1)

result = results[0]
try:
normalized = normalize_transcript_result(result)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)

if not normalized["segments"]:
print("Error: No transcript found for this video.", file=sys.stderr)
print("The video might not have captions available.", file=sys.stderr)
sys.exit(1)

# Normalize output: extract segments from the `data` field
segments = result.get("data", [])
return {
"title": result.get("title", "Unknown"),
"language": result.get("language", "unknown"),
"segments": segments
}
return normalized

except requests.exceptions.RequestException as e:
print(f"Error fetching results: {e}", file=sys.stderr)
Expand Down Expand Up @@ -433,8 +531,6 @@ def process_batch(batch_file, api_token, use_cache, language, output_json):
# Print summary
print(f"\n{'='*40}", file=sys.stderr)
print(f"Batch complete: {fetched} fetched, {cached} cached, {failed} failed", file=sys.stderr)
if fetched > 0:
print(f"[Cost: ~${fetched * 0.007:.3f} for {fetched} API call(s)]", file=sys.stderr)

return results

Expand Down Expand Up @@ -571,9 +667,6 @@ def main():
else:
print(output)

if not from_cache:
print("\n[Cost: ~$0.007 per video]", file=sys.stderr)


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Offline adapter tests for youtube-apify-transcript normalize helpers."""

from __future__ import annotations

import sys
import unittest
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SCRIPT_DIR))

from fetch_transcript import build_actor_input, normalize_transcript_result


class BuildActorInputTests(unittest.TestCase):
"""Cover johnvc/YoutubeTranscripts run input."""

def test_url_only(self) -> None:
"""A watch URL becomes youtube_url with no language list."""
payload = build_actor_input("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
self.assertEqual(
payload,
{"youtube_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"},
)

def test_language_list(self) -> None:
"""--lang becomes a one-item languages array."""
payload = build_actor_input(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
language="de",
)
self.assertEqual(payload["languages"], ["de"])


class NormalizeTranscriptResultTests(unittest.TestCase):
"""Cover live johnvc rows, cached pintostudio rows, and failures."""

def test_johnvc_timestamped_duration_maps_to_dur(self) -> None:
"""Live Actor rows keep the skill's start/dur/text field names."""
normalized = normalize_transcript_result(
{
"success": True,
"error": False,
"title": "Example",
"language": "en",
"timestamped": [
{"start": 0.0, "duration": 2.5, "text": "Hello"},
{"start": 2.5, "duration": 3.0, "text": "world"},
],
}
)
self.assertEqual(normalized["title"], "Example")
self.assertEqual(normalized["language"], "en")
self.assertEqual(
normalized["segments"],
[
{"start": 0.0, "dur": 2.5, "text": "Hello"},
{"start": 2.5, "dur": 3.0, "text": "world"},
],
)

def test_cached_pintostudio_data_rows_still_parse(self) -> None:
"""Older cache files that stored data[] with dur keep working."""
normalized = normalize_transcript_result(
{
"title": "Cached",
"language": "en",
"data": [
{"start": "1.0", "dur": "1.5", "text": "cached cue"},
],
}
)
self.assertEqual(
normalized["segments"],
[{"start": 1.0, "dur": 1.5, "text": "cached cue"}],
)

def test_failed_actor_row_raises(self) -> None:
"""A success=false dataset item becomes a ValueError."""
with self.assertRaises(ValueError) as caught:
normalize_transcript_result(
{
"success": False,
"error": True,
"error_message": "captions unavailable",
"title": "Missing",
}
)
self.assertIn("captions unavailable", str(caught.exception))


if __name__ == "__main__":
unittest.main()