Add youtube transcript skill #22
@@ -1,3 +1,10 @@
|
|||||||
# skills
|
# skills
|
||||||
|
|
||||||
Personal agent skills packaged through Nix.
|
Personal agent skills packaged through Nix.
|
||||||
|
|
||||||
|
## Skill sources
|
||||||
|
|
||||||
|
Some skills adapt workflows or helper scripts from public examples.
|
||||||
|
|
||||||
|
- `axi-review` is based on the [AXI principles](https://axi.md/).
|
||||||
|
- `youtube-transcript` is based on [`amosblomqvist/pi-config/skills/youtube-transcript`](https://github.com/amosblomqvist/pi-config/tree/main/skills/youtube-transcript).
|
||||||
|
|||||||
18
flake.nix
18
flake.nix
@@ -32,7 +32,23 @@
|
|||||||
nixpkgs.lib.listToAttrs (
|
nixpkgs.lib.listToAttrs (
|
||||||
map (skill: {
|
map (skill: {
|
||||||
inherit (skill) name;
|
inherit (skill) name;
|
||||||
value = mkSkill { inherit pkgs; inherit (skill) name src; };
|
value = mkSkill (
|
||||||
|
{
|
||||||
|
inherit pkgs;
|
||||||
|
inherit (skill) name src;
|
||||||
|
}
|
||||||
|
// nixpkgs.lib.optionalAttrs (skill.name == "youtube-transcript") {
|
||||||
|
commands = [
|
||||||
|
{
|
||||||
|
path = "fetch_transcript";
|
||||||
|
env = {
|
||||||
|
PYTHON = "${pkgs.python3}/bin/python3";
|
||||||
|
YT_DLP = "${pkgs.yt-dlp}/bin/yt-dlp";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
);
|
||||||
}) discoveredSkills
|
}) discoveredSkills
|
||||||
);
|
);
|
||||||
in
|
in
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# The 10 AXI Principles — review rubric
|
# The 10 AXI Principles — review rubric
|
||||||
|
|
||||||
The canonical statements come from axi.md, which is the authority for the wording.
|
The canonical statements come from [`axi.md`](https://axi.md/), which is the authority for the wording.
|
||||||
Each entry distils the canonical statement, gives the black-box probe to run, and the criteria that map observed output to a verdict.
|
Each entry distils the canonical statement, gives the black-box probe to run, and the criteria that map observed output to a verdict.
|
||||||
Judge against the canonical statement, not against any single tool's interpretation.
|
Judge against the canonical statement, not against any single tool's interpretation.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: axi-review
|
name: axi-review
|
||||||
description: Review a CLI for conformance to the 10 AXI (Agent eXperience Interface) principles by running it black-box and scoring each principle on a report card. Use when asked to review, audit, check, or measure a CLI's AXI conformance, or when "AXI principles", "Agent eXperience Interface", or axi.md is named.
|
description: Review a CLI for conformance to the 10 AXI (Agent eXperience Interface) principles by running it black-box and scoring each principle on a report card. Use when asked to review, audit, check, or measure a CLI's AXI conformance, or when "AXI principles", "Agent eXperience Interface", or https://axi.md/ is named.
|
||||||
---
|
---
|
||||||
|
|
||||||
# axi-review
|
# axi-review
|
||||||
|
|||||||
45
skills/youtube-transcript/SKILL.md
Normal file
45
skills/youtube-transcript/SKILL.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
name: youtube-transcript
|
||||||
|
description: Fetch the transcript and title of a YouTube video as JSON. Use when the user provides a YouTube URL and needs spoken captions for analysis, summarization, quoting, or search.
|
||||||
|
---
|
||||||
|
|
||||||
|
# YouTube Transcript
|
||||||
|
|
||||||
|
Fetch a YouTube video's title and full transcript with `yt-dlp` captions.
|
||||||
|
Prefer manual English subtitles.
|
||||||
|
Fall back to automatic English subtitles.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
The Nix-packaged skill provides Python 3 and `yt-dlp`.
|
||||||
|
A source checkout requires Python 3 and `yt-dlp` on `PATH`.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Run from this skill directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./fetch_transcript "<youtube_url>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Prints a JSON object to stdout:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Video title",
|
||||||
|
"transcript": "full transcript text as a single string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Progress logs go to stderr.
|
||||||
|
On failure, the script exits non-zero and writes the error to stderr.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Only English captions are attempted.
|
||||||
|
- The language order is `en`, `en-US`, `en-GB`, then any `en*` caption.
|
||||||
|
- Manual captions are preferred over automatic captions.
|
||||||
|
- The transcript is plain text with timing and formatting removed.
|
||||||
|
- Videos without English captions fail.
|
||||||
9
skills/youtube-transcript/fetch_transcript
Executable file
9
skills/youtube-transcript/fetch_transcript
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
|
||||||
|
: "${PYTHON:=python3}" # mkSkill:PYTHON
|
||||||
|
: "${YT_DLP:=yt-dlp}" # mkSkill:YT_DLP
|
||||||
|
|
||||||
|
exec "$PYTHON" "$dir/fetch_transcript.py" --yt-dlp "$YT_DLP" "$@"
|
||||||
139
skills/youtube-transcript/fetch_transcript.py
Executable file
139
skills/youtube-transcript/fetch_transcript.py
Executable file
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fetch a YouTube video's title and transcript through yt-dlp."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
def get_metadata(yt_dlp, url):
|
||||||
|
cmd = [
|
||||||
|
yt_dlp,
|
||||||
|
"--dump-json",
|
||||||
|
"--no-warnings",
|
||||||
|
"--skip-download",
|
||||||
|
url,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
except subprocess.CalledProcessError as error:
|
||||||
|
print(f"Error executing yt-dlp: {error.stderr}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as error:
|
||||||
|
print(f"Unexpected error: {error}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def pick_english_lang(info):
|
||||||
|
"""Return (lang_code, is_auto) for the best English caption track."""
|
||||||
|
subs = info.get("subtitles") or {}
|
||||||
|
auto = info.get("automatic_captions") or {}
|
||||||
|
|
||||||
|
preferred = ["en", "en-US", "en-GB"]
|
||||||
|
|
||||||
|
for lang in preferred:
|
||||||
|
if lang in subs:
|
||||||
|
return lang, False
|
||||||
|
for lang in subs:
|
||||||
|
if lang.startswith("en"):
|
||||||
|
return lang, False
|
||||||
|
for lang in preferred:
|
||||||
|
if lang in auto:
|
||||||
|
return lang, True
|
||||||
|
for lang in auto:
|
||||||
|
if lang.startswith("en"):
|
||||||
|
return lang, True
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def download_subtitle(yt_dlp, url, lang, is_auto, outdir):
|
||||||
|
cmd = [
|
||||||
|
yt_dlp,
|
||||||
|
"--skip-download",
|
||||||
|
"--no-warnings",
|
||||||
|
"--sub-format",
|
||||||
|
"json3",
|
||||||
|
"--sub-langs",
|
||||||
|
lang,
|
||||||
|
"--write-auto-subs" if is_auto else "--write-subs",
|
||||||
|
"-o",
|
||||||
|
os.path.join(outdir, "%(id)s.%(ext)s"),
|
||||||
|
url,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
except subprocess.CalledProcessError as error:
|
||||||
|
print(f"Error downloading subtitles with yt-dlp: {error.stderr}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
matches = glob.glob(os.path.join(outdir, "*.json3"))
|
||||||
|
if not matches:
|
||||||
|
print("yt-dlp did not produce a json3 subtitle file.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
|
||||||
|
def extract_text_from_json3(path):
|
||||||
|
with open(path, "r", encoding="utf-8") as file:
|
||||||
|
data = json.load(file)
|
||||||
|
|
||||||
|
text_parts = []
|
||||||
|
for event in data.get("events", []):
|
||||||
|
for segment in event.get("segs", []) or []:
|
||||||
|
text = segment.get("utf8", "")
|
||||||
|
if text.strip() and text != "\n":
|
||||||
|
text_parts.append(text.strip())
|
||||||
|
return " ".join(text_parts)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Fetch a YouTube video's title and English transcript as JSON."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"url",
|
||||||
|
metavar="youtube_url",
|
||||||
|
help="YouTube URL or video ID.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--yt-dlp",
|
||||||
|
default="yt-dlp",
|
||||||
|
help="Path to yt-dlp. Defaults to yt-dlp from PATH.",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
print("Fetching video metadata...", file=sys.stderr)
|
||||||
|
info = get_metadata(args.yt_dlp, args.url)
|
||||||
|
title = info.get("title", "Unknown_Title")
|
||||||
|
|
||||||
|
pick = pick_english_lang(info)
|
||||||
|
if pick is None:
|
||||||
|
print(f"No English subtitles found for video: {title}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
lang, is_auto = pick
|
||||||
|
|
||||||
|
tmpdir = tempfile.mkdtemp(prefix="yt-transcript-")
|
||||||
|
try:
|
||||||
|
caption_kind = "auto captions" if is_auto else "captions"
|
||||||
|
print(f"Downloading {caption_kind} ({lang})...", file=sys.stderr)
|
||||||
|
sub_path = download_subtitle(args.yt_dlp, args.url, lang, is_auto, tmpdir)
|
||||||
|
transcript = extract_text_from_json3(sub_path)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
|
output = {"title": title, "transcript": transcript}
|
||||||
|
print(json.dumps(output, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user