429f3143e5
Move the skill into skills/watch/ so SKILL.md and its scripts/ runtime are
siblings inside one folder. `npx skills add` (Codex/Cursor/Copilot/agents) now
copies a working skill as a unit; previously it grabbed the root SKILL.md alone
and left scripts/ behind, so the skill was dead on arrival on every non-Claude
host. Mirrors the layout last30days-skill adopted for the same reason.
- skills/watch/{SKILL.md,scripts/}: self-contained skill folder
- SKILL.md: resolve a harness-agnostic $SKILL_DIR (the dir it was Read from)
instead of the Claude-Code-only ${CLAUDE_SKILL_DIR}; guard + 19 call sites
- drop commands/watch.md: /watch derives from frontmatter (name + user-invocable)
- .codex-plugin/plugin.json: full manifest with "skills": "./skills/" + interface
- add .agents/plugins/marketplace.json, AGENTS.md, CLAUDE.md, .skillignore
- build-skill.sh: archive the skills/watch subtree (one SKILL.md, no zip -d)
- fix paths in tests, hooks hint, .gitattributes, release.yml
- relocate dev-sync.sh to repo root and fix REPO_ROOT
- README: content-ideas structure, npx skills install, star history
Verified: 37/37 tests pass; npx skills add bundles the full scripts/ runtime;
manifests valid; versions synced at 0.1.3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
97 lines
2.8 KiB
Python
Executable File
97 lines
2.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Parse a WebVTT subtitle file into a clean, timestamped transcript.
|
|
|
|
YouTube auto-subs emit rolling-duplicate cues (each line appears 2-3 times as it
|
|
scrolls). We dedupe consecutive identical cues and merge their time ranges.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
TS_RE = re.compile(
|
|
r"(\d{2}):(\d{2}):(\d{2})[.,](\d{3})\s+-->\s+(\d{2}):(\d{2}):(\d{2})[.,](\d{3})"
|
|
)
|
|
TAG_RE = re.compile(r"<[^>]+>")
|
|
|
|
|
|
def _to_seconds(h: str, m: str, s: str, ms: str) -> float:
|
|
return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0
|
|
|
|
|
|
def parse_vtt(path: str) -> list[dict]:
|
|
text = Path(path).read_text(encoding="utf-8", errors="ignore")
|
|
lines = text.splitlines()
|
|
|
|
segments: list[dict] = []
|
|
i = 0
|
|
while i < len(lines):
|
|
match = TS_RE.match(lines[i])
|
|
if not match:
|
|
i += 1
|
|
continue
|
|
|
|
start = _to_seconds(*match.groups()[:4])
|
|
end = _to_seconds(*match.groups()[4:])
|
|
i += 1
|
|
|
|
cue_lines: list[str] = []
|
|
while i < len(lines) and lines[i].strip():
|
|
cleaned = TAG_RE.sub("", lines[i]).strip()
|
|
if cleaned:
|
|
cue_lines.append(cleaned)
|
|
i += 1
|
|
|
|
cue_text = " ".join(cue_lines).strip()
|
|
if cue_text:
|
|
segments.append({"start": round(start, 2), "end": round(end, 2), "text": cue_text})
|
|
i += 1
|
|
|
|
return _dedupe(segments)
|
|
|
|
|
|
def _dedupe(segments: list[dict]) -> list[dict]:
|
|
"""Collapse rolling duplicates common in YouTube auto-subs."""
|
|
out: list[dict] = []
|
|
for seg in segments:
|
|
if out and seg["text"] == out[-1]["text"]:
|
|
out[-1]["end"] = seg["end"]
|
|
continue
|
|
if out and seg["text"].startswith(out[-1]["text"] + " "):
|
|
out[-1]["text"] = seg["text"]
|
|
out[-1]["end"] = seg["end"]
|
|
continue
|
|
out.append(seg)
|
|
return out
|
|
|
|
|
|
def filter_range(
|
|
segments: list[dict],
|
|
start_seconds: float | None,
|
|
end_seconds: float | None,
|
|
) -> list[dict]:
|
|
"""Return segments whose time range overlaps [start, end]."""
|
|
if start_seconds is None and end_seconds is None:
|
|
return segments
|
|
lo = start_seconds if start_seconds is not None else float("-inf")
|
|
hi = end_seconds if end_seconds is not None else float("inf")
|
|
return [seg for seg in segments if seg["end"] >= lo and seg["start"] <= hi]
|
|
|
|
|
|
def format_transcript(segments: list[dict]) -> str:
|
|
lines = []
|
|
for seg in segments:
|
|
start = int(seg["start"])
|
|
stamp = f"[{start // 60:02d}:{start % 60:02d}]"
|
|
lines.append(f"{stamp} {seg['text']}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2:
|
|
print("usage: transcribe.py <vtt-path>", file=sys.stderr)
|
|
raise SystemExit(2)
|
|
print(format_transcript(parse_vtt(sys.argv[1])))
|