feat(youtube): 使用 youtube-transcript-api 优先获取字幕,有字幕时跳过音频下载

- 新增 YouTubeSubtitleFetcher 模块,通过 youtube-transcript-api 获取字幕
- 重构笔记生成流程:缓存 → 平台字幕 → 按需下载 → 转写 fallback
- 有字幕时仅提取视频元信息,不下载音视频文件
- 添加 youtube-transcript-api 依赖

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
huangjianwu
2026-03-23 17:31:30 +08:00
parent 5861ef4168
commit f4801d5be7
5 changed files with 203 additions and 138 deletions

View File

@@ -133,8 +133,46 @@ class NoteGenerator:
audio_cache_file = NOTE_OUTPUT_DIR / f"{task_id}_audio.json"
transcript_cache_file = NOTE_OUTPUT_DIR / f"{task_id}_transcript.json"
markdown_cache_file = NOTE_OUTPUT_DIR / f"{task_id}_markdown.md"
print(audio_cache_file)
# 1. 下载音频/视频
# 1. 获取字幕/转写:优先缓存 → 平台字幕 → 音频转写
transcript = None
# 尝试读取缓存
if transcript_cache_file.exists():
logger.info(f"检测到转写缓存 ({transcript_cache_file}),尝试读取")
try:
data = json.loads(transcript_cache_file.read_text(encoding="utf-8"))
segments = [TranscriptSegment(**seg) for seg in data.get("segments", [])]
transcript = TranscriptResult(
language=data.get("language"),
full_text=data["full_text"],
segments=segments,
)
logger.info(f"已从缓存加载转写结果,共 {len(segments)}")
except Exception as e:
logger.warning(f"加载转写缓存失败: {e}")
# 缓存没有,尝试获取平台字幕
if transcript is None:
logger.info("尝试获取平台字幕(优先于音频下载)...")
try:
transcript = downloader.download_subtitles(video_url)
if transcript and transcript.segments:
logger.info(f"成功获取平台字幕,共 {len(transcript.segments)}")
transcript_cache_file.write_text(
json.dumps(asdict(transcript), ensure_ascii=False, indent=2),
encoding="utf-8",
)
else:
transcript = None
logger.info("平台无可用字幕,将下载音频后转写")
except Exception as e:
logger.warning(f"获取平台字幕失败: {e},将下载音频后转写")
transcript = None
# 2. 下载音频/视频
# 有字幕时只提取元信息,不下载音视频文件(除非需要截图/视频理解)
has_transcript = transcript is not None
need_full_download = not has_transcript or screenshot or video_understanding
audio_meta = self._download_media(
downloader=downloader,
video_url=video_url,
@@ -147,18 +185,19 @@ class NoteGenerator:
video_understanding=video_understanding,
video_interval=video_interval,
grid_size=grid_size,
skip_download=not need_full_download,
)
# 2. 获取字幕/转写文字
# 优先尝试获取平台字幕,没有再 fallback 到音频转写
transcript = self._get_transcript(
downloader=downloader,
video_url=video_url,
audio_file=audio_meta.file_path,
transcript_cache_file=transcript_cache_file,
status_phase=TaskStatus.TRANSCRIBING,
task_id=task_id,
)
# 3. 如果前面没拿到字幕,走转写流程
if transcript is None:
transcript = self._get_transcript(
downloader=downloader,
video_url=video_url,
audio_file=audio_meta.file_path,
transcript_cache_file=transcript_cache_file,
status_phase=TaskStatus.TRANSCRIBING,
task_id=task_id,
)
# 3. GPT 总结
markdown = self._summarize_text(
@@ -331,6 +370,7 @@ class NoteGenerator:
video_understanding: bool,
video_interval: int,
grid_size: List[int],
skip_download: bool = False,
) -> AudioDownloadResult | None:
"""
1. 检查音频缓存;若不存在,则根据需要下载音频或视频(若需截图/可视化)。
@@ -353,7 +393,34 @@ class NoteGenerator:
task_id = audio_cache_file.stem.split("_")[0]
self._update_status(task_id, status_phase)
# 已有缓存,尝试加载
if audio_cache_file.exists():
logger.info(f"检测到音频缓存 ({audio_cache_file}),直接读取")
try:
data = json.loads(audio_cache_file.read_text(encoding="utf-8"))
return AudioDownloadResult(**data)
except Exception as e:
logger.warning(f"读取音频缓存失败,将重新下载:{e}")
# 有字幕且不需要截图/视频理解时,只提取元信息不下载文件
if skip_download:
logger.info("已有字幕,仅提取视频元信息(不下载音视频)")
try:
audio = downloader.download(
video_url=video_url,
quality=quality,
output_dir=output_path,
need_video=False,
skip_download=True,
)
audio_cache_file.write_text(
json.dumps(asdict(audio), ensure_ascii=False, indent=2),
encoding="utf-8",
)
logger.info(f"元信息提取完成 ({audio_cache_file})")
return audio
except Exception as exc:
logger.warning(f"元信息提取失败,将尝试完整下载: {exc}")
# 判断是否需要下载视频
need_video = screenshot or video_understanding
@@ -368,9 +435,8 @@ class NoteGenerator:
self.video_path = Path(video_path_str)
logger.info(f"视频下载完成:{self.video_path}")
# 若指定了 grid_size则生成缩略图
if grid_size:
self.video_img_urls=VideoReader(
self.video_img_urls = VideoReader(
video_path=str(self.video_path),
grid_size=tuple(grid_size),
frame_interval=frame_interval,
@@ -382,17 +448,9 @@ class NoteGenerator:
logger.info("未指定 grid_size跳过缩略图生成")
except Exception as exc:
logger.error(f"视频下载失败:{exc}")
self._handle_exception(task_id, exc)
raise
# 已有缓存,尝试加载
if audio_cache_file.exists():
logger.info(f"检测到音频缓存 ({audio_cache_file}),直接读取")
try:
data = json.loads(audio_cache_file.read_text(encoding="utf-8"))
return AudioDownloadResult(**data)
except Exception as e:
logger.warning(f"读取音频缓存失败,将重新下载:{e}")
# 下载音频
try:
logger.info("开始下载音频")
@@ -402,7 +460,6 @@ class NoteGenerator:
output_dir=output_path,
need_video=need_video,
)
# 缓存 audio 元信息到本地 JSON
audio_cache_file.write_text(json.dumps(asdict(audio), ensure_ascii=False, indent=2), encoding="utf-8")
logger.info(f"音频下载并缓存成功 ({audio_cache_file})")
return audio