
Manabi Ingest
- 4 installs
- Updated July 30, 2026
- naoterumaker/manabi-skills
Helps with ai & agent building tasks.
About
manabi-ingest is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- manabi-ingest
- AI & Agent Building
- AI-coding skill
Manabi Ingest by the numbers
- 4 all-time installs (skills.sh)
- Ranked #13,372 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/naoterumaker/manabi-skills --skill manabi-ingestAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4 |
|---|---|
| Last updated | July 30, 2026 |
| Repository | naoterumaker/manabi-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Manabi Ingest スキル(学習素材オーケストレーター)
WHAT
動画講座コンテンツを標準フォーマット(course-bundle)に変換し、 ユーザーが選んだレベルに応じてナレッジ抽出・マニュアル生成・スキル化まで一気通貫で実行する。
WHY
- 講座取り込みから成果物生成まで手作業で繋ぐのは非効率
- 標準フォーマットがあれば後続処理(ナレッジ抽出、マニュアル生成、スキル化)を共通化できる
- レベル選択により、必要な深さだけ処理できる
HOW
Phase A: Course-Bundle 作成
Step 0: 入力タイプの判定
| 入力 | 判定方法 | 処理パイプライン |
|---|---|---|
| UTAGE URL | utage-system.com を含むURL | Chrome抽出→HLS DL→文字起こし→スクショ→ページテキスト・リンク抽出 |
| YouTube URL | youtube.com or youtu.be を含むURL | video-downloaderでDL→文字起こし→スクショ |
| Udemy URL | udemy.com を含むURL | Chrome連携→動画DL→文字起こし→スクショ→補足資料取得 |
| Loom URL | loom.com を含むURL | 動画DL→文字起こし→スクショ→説明文取得 |
| ローカル動画ファイル | .mp4, .mkv, .webm などのファイルパス | 文字起こし→スクショ |
| 既存transcript+screenshots | ディレクトリパス(transcripts/やscreenshots/を含む) | normalize_bundle.pyで正規化のみ |
BLOCKER: 入力タイプが判定できない場合はユーザーに確認する。勝手に推測して進めない。
対応プラットフォーム:
- UTAGE: 動画+説明文+リンク+配布物
- Udemy: 動画+補足資料+リンク
- Loom: 動画+説明文
- YouTube: 動画+概要欄
- ローカル: 動画ファイル or 既存データ
- テキストのみの章: ページ全文取得(動画なし)
講座にはテキストだらけの章、動画中心の章、外部リンクが多い章など 様々なパターンがある。Chrome連携時に検知したコンテンツは 可能な限り取得する。必須ではないが、あれば品質が上がる。
Step 1: ヒアリング
取り込み処理を開始する前に、講座の概要をユーザーに聞く。 この情報があると、リソース検知の精度とスキル計画の質が大幅に上がる。
📦 Manabi Ingest
入力: [判定した入力タイプと内容]
出力先: [出力ディレクトリ]
📋 講座について教えてください(わかる範囲で)
・どんな内容の講座ですか?(一言で)
・配布物はありますか?
(テンプレート、PDF、Notionページ、スプレッドシート等)
・この講座から作りたいスキルのイメージはありますか?
例: 「AI台本作成の講座。Notionにプロンプトテンプレートが
何個かある。テンプレートを使って台本自動生成するスキルが
作れるといい」ユーザーの回答を manifest.json に保存:
{
"user_context": {
"description": "AI台本作成の講座",
"expected_resources": ["Notionプロンプトテンプレート"],
"skill_hypotheses": ["テンプレートで台本自動生成"],
"notes": ""
}
}この情報は以降のフェーズで活用する:
- リソース検知時: expected_resourcesを手がかりにNotionリンク等を重点チェック
- skill-planner: skill_hypothesesとの照合で仮説→検証の流れを作る
Step 1.5: 処理計画提示
ヒアリング回答を受けたら、処理を開始する前に具体的な計画をユーザーに提示する:
📋 処理計画
- 検出: N章
- DL予定: N本 (推定 X分)
- 並列度: 5並列ダウンロード
- 文字起こし: 6 Agent並列 (sonnet)
- スクショ抽出: 並列
- 推定所要時間: 約X分
この計画で進めますか?BLOCKER: ユーザーの確認を得るまで取り込み処理を開始しない。
Step 2: 入力タイプ別パイプライン
UTAGE URL の場合
1. utage-manualスキルの手順でChrome連携→章一覧取得 2. 各章のHLS URLを取得 3. ~/.claude/skills/utage-manual/scripts/hls_downloader.py でダウンロード 4. ~/.claude/skills/utage-manual/scripts/transcribe.py で文字起こし 5. ~/.claude/skills/utage-manual/scripts/transcribe.py --timestamps でタイムスタンプ付きJSON生成 6. ~/.claude/skills/utage-manual/scripts/screenshot_extractor.py でスクリーンショット抽出(pHashモード・デフォルト) 7. ページテキスト・リンク抽出(後述) 8. Step 3 へ
YouTube URL の場合
1. video-downloaderスキルで動画をダウンロード 2. ~/.claude/skills/utage-manual/scripts/transcribe.py で文字起こし 3. ~/.claude/skills/utage-manual/scripts/transcribe.py --timestamps でタイムスタンプ付きJSON生成 4. ~/.claude/skills/utage-manual/scripts/screenshot_extractor.py でスクリーンショット抽出(pHashモード・デフォルト) 5. Step 3 へ
ローカル動画ファイルの場合
1. ~/.claude/skills/utage-manual/scripts/transcribe.py で文字起こし 2. ~/.claude/skills/utage-manual/scripts/transcribe.py --timestamps でタイムスタンプ付きJSON生成 3. ~/.claude/skills/utage-manual/scripts/screenshot_extractor.py でスクリーンショット抽出(pHashモード・デフォルト) 4. Step 3 へ
既存transcript+screenshots の場合
1. 直接 Step 3 へ
スクリーンショット抽出について
screenshot_extractor.py はpHash (perceptual hash) モードをデフォルトで使用:
- スライド切替・UI変化を正確に検出
- 重複フレームを自動排除
- コンテンツ変化量に応じて枚数が自動調整
- スライド中心章 → 少なめ(例: 6分動画で8枚)
- デモ中心章 → 多め(例: 17分動画で45枚)
- 30秒固定間隔モードはレガシー扱い(
--no-phashで呼び出し)
依存: pip install imagehash Pillow
Step 2.5: ページテキスト・リンク抽出(Chrome連携時)
各章のページにアクセスした際、動画以外のコンテンツも取得する:
1. テキスト抽出: 動画の下/周辺にある説明文・補足テキストを取得 2. リンク抽出: ページ内の全リンクを収集
- Notion ページ、Google Drive、PDF ダウンロードリンク等
- 「プレゼント」「特典」「配布物」セクションを重点チェック
3. 保存先:
chapters/{id}/page_text.md— ページのテキストコンテンツchapters/{id}/links.json— 検出したリンク一覧
links.json の形式:
[
{
"text": "リンクテキスト",
"url": "https://...",
"context": "特典セクション",
"type": "notion|gdrive|pdf|other"
}
]Step 2.6: 外部リソース取得
全章のリンク収集後、フェッチ可能なリソースを取得する:
1. 全章の links.json を集約 2. フェッチ可能なリソースを検出(Notion ページ、PDF、テンプレート等) 3. Chrome 経由でアクセス可能なものを取得 4. 保存先: resources/ ディレクトリ(種別ごとにサブディレクトリ) 5. resources-manifest.json を生成
resources-manifest.json の形式:
{
"fetched": [
{
"source_url": "https://...",
"source_chapter": "03",
"type": "pdf|notion|template",
"local_path": "resources/pdfs/filename.pdf",
"title": "リソースタイトル",
"status": "fetched|failed|skipped"
}
]
}NOTE: リソース取得は best-effort。認証が必要なものやアクセスできないものは skipped にして続行。
Step 3: normalize_bundle.py で正規化
python ~/.claude/skills/manabi-ingest/scripts/normalize_bundle.py \
--input "/path/to/source_dir" \
--output "/path/to/course-bundle" \
--course-name "講座名" \
--speaker "講師名" \
--source-type "utage|youtube|local"BLOCKER: 正規化前に、以下を確認する:
- transcripts/ ディレクトリに .txt ファイルが存在すること
- screenshots/ ディレクトリにサブディレクトリが存在すること
Step 4: manifest.json のバリデーション
正規化後、manifest.json の全パスが実在するファイルを指しているか検証する。
python -c "
import json, os, sys
with open(sys.argv[1]) as f:
m = json.load(f)
ok = True
for ch in m['chapters']:
for key in ['transcript_path', 'transcript_ts_path', 'screenshots_dir']:
path = os.path.join(os.path.dirname(sys.argv[1]), ch.get(key, ''))
if path and not os.path.exists(path):
print(f'MISSING: {path}')
ok = True if key == 'transcript_ts_path' else False
if ok:
print('All paths valid')
else:
print('VALIDATION FAILED')
sys.exit(1)
" "/path/to/course-bundle/manifest.json"BLOCKER: バリデーション失敗時は処理を中断し、ユーザーに報告する。
Step 5: Bundle 完了報告 + レベル選択
✅ Course Bundle 作成完了
📁 出力先: [出力ディレクトリ]
📋 章数: [N]章
📊 ステータス:
| 章 | タイトル | transcript | screenshots | page_text | links | status |
|----|---------|-----------|-------------|-----------|-------|--------|
| 00 | [title] | ✅ | ✅ (31枚) | ✅ | 3件 | complete |
| 01 | [title] | ✅ | ✅ (25枚) | ❌ | 0件 | complete |
...
manifest.json のパスバリデーション: ✅ 全パス正常
外部リソース: [N]件取得 / [M]件スキップ直後にレベル選択を提示:
どこまで処理しますか?
1️⃣ ナレッジ抽出のみ
→ 概念・暗黙知・手順・画像分類を構造化
2️⃣ ナレッジ + マニュアル生成
→ 上記 + 画像付きMarkdownマニュアル(人間向け)
3️⃣ ナレッジ + マニュアル + スキル化
→ 上記 + 実行可能なClaude Codeスキル自動生成
→ スキル化前にプランを提示して承認を取りますBLOCKER: ユーザーの選択を待つ。勝手にレベルを決めない。
---
Phase B: レベル別パイプライン実行
Level 1: ナレッジ抽出
実行順序(各extractor は sonnet & 並列必須): 1. concept-extractor sonnet, 4並列(章を分割して4 Agent同時起動)— マニュアルがあればマニュアルを一次ソースとして使用 2. visual-indexer sonnet, 4並列(コスト重いため並列必須)— スクリーンショットの分類・タグ付け 3. procedure-extractor sonnet, 4並列 — visual-index 完了後に実行(画像参照が必要なため)
Level 2: ナレッジ + マニュアル生成
Level 1 の全処理に加え: 4. utage-manual sonnet, 6並列 — 画像付き Markdown マニュアル生成 5. レビュー必須: マニュアル生成後は必ず image-alignment.md・writing-style.md に従ってレビュー実施(省略不可) 6. concept-extractor がマニュアルを一次ソースとして再抽出可能(より高品質なナレッジ)
Level 3: ナレッジ + マニュアル + スキル化
Level 2 の全処理に加え: 7. skill-planner opus — スキル化プランを生成してユーザーに提示
- BLOCKER: プランへのユーザー承認を待つ
8. skill-synthesizer opus — 承認されたスキルを生成(staging dir 経由で cp、~/.claude/skills/ への直接書き込み不可)
---
並列実行パターン
各extractor呼び出し時は必ず以下のテンプレートに従う:
Agent起動時の必須パラメータ:
- model: "sonnet" ← extractor系は必ずsonnet
- run_in_background: true
- mode: "bypassPermissions"
章をN分割してN Agent並列起動:
- 36章 → 4-6 Agent (各6-9章担当)
- visual-indexer: 特にコスト重い → 必ずsonnet & 並列必須並列度の目安:
| 処理 | 並列数 | モデル |
|---|---|---|
| concept-extractor | 4並列 | sonnet |
| visual-indexer | 4並列 | sonnet |
| procedure-extractor | 4並列 | sonnet |
| マニュアル生成 | 6並列 | sonnet |
| skill-planner | 1(単発) | opus |
| skill-synthesizer | 1(単発) | opus |
---
レビューチェックポイント(先行1章方式)
全章レビューは遅い。全くレビューしないと品質崩壊。 解決策: 1-2章だけ先行完了させてサンプルレビュー → OKなら残り章を並列一気処理。
4つのチェックポイント
Phase A: 取り込み
↓
🔍 レビューA: 1章のtranscript + 5枚スクショをサンプル確認
→ 文字起こし精度OK?スクショ枚数・内容OK?
→ NG: 設定変更(chunk_seconds, interval等)して再実行
→ OK: 全章並列処理を継続
Phase B Level 1: ナレッジ抽出
↓
🔍 レビューB: 1章のknowledge.json + visual-index.json + procedures.json
→ 概念抽出の粒度OK?画像分類OK?手順の粒度OK?
→ NG: プロンプト調整して再実行
→ OK: 残り全章の並列処理を継続
Phase B Level 2: マニュアル生成
↓
🔍 レビューC: 2章のマニュアル(短い章+長い章)
→ 文章スタイルOK?画像配置OK?整合性OK?
→ NG: writing-style.md参照 + 該当章のみ再生成
→ OK: 残り全章継続
Phase B Level 3: skill-planner完了後
↓
✅ プラン承認BLOCKER(既存)
↓
Phase B Level 3: skill-synthesizer完了後
↓
🔍 レビューD: 1スキルのSKILL.mdサンプル確認
→ 実行可能?プロンプトテンプレ入ってる?
→ NG: synthesizer再実行
→ OK: 完了報告サンプル選定ルール
| レビュー | サンプル選定 |
|---|---|
| レビューA | 中位の章(1章目は導入で短いことが多い) |
| レビューB | 概念・手順両方含む章(分類できないから) |
| レビューC | 短い章 + 長い章の2本(両極の品質を確認) |
| レビューD | 最初に完成したexecutable skill(1本で良い) |
並列処理との両立
並列処理は止めない。以下のやり方でサンプルレビューを組み込む:
1. 先行1章方式: 全Agent起動するが、1つのAgentだけ1章担当にする 2. その1章が最初に完了 → サンプル提示 → ユーザー確認 3. 確認中も残りAgentは処理継続 4. 確認NG → 残りAgentを停止 → プロンプト調整 → 再起動 5. 確認OK → そのまま全章完了を待つ
BLOCKER
- レビューDのみ承認必須BLOCKER。
- レビューA/B/Cは警告扱い(確認しながら処理継続可)。
- 重大な問題(パス不整合・エラー率高)が検出された場合のみ処理停止。
---
出力フォーマット: course-bundle
course-bundle/
manifest.json
chapters/
00/
transcript.txt
transcript_ts.json
screenshots/
title.jpg
frame_001.jpg ...
page_text.md
links.json
manual.md ← utage-manual output (Level 2+)
01/ ...
resources/
prompts/
pdfs/
templates/
resources-manifest.jsonmanifest.json の構造
スキーマ: ~/.claude/skills/manabi-ingest/schemas/manifest.schema.json
{
"course_name": "YouTube Booster",
"source_type": "utage",
"source_url": "https://...",
"language": "ja",
"speaker": "おさる",
"chapters": [
{
"id": "03",
"title": "生成AIを使いこなす4つの原理原則",
"duration_seconds": 612,
"transcript_path": "chapters/03/transcript.txt",
"transcript_ts_path": "chapters/03/transcript_ts.json",
"screenshots_dir": "chapters/03/screenshots/",
"screenshot_count": 31,
"page_text_path": "chapters/03/page_text.md",
"links_path": "chapters/03/links.json",
"status": "complete"
}
]
}---
NG / OK パターン
| NG | OK | 理由 |
|---|---|---|
| ユーザー確認なしで処理開始 | Step 1で確認してから開始 | 出力先や講座名の確認が必要 |
| manifest.jsonのパスを検証せずに完了報告 | Step 4で全パスを検証 | 壊れたbundleを渡すと後続処理が全部失敗する |
| 存在しないtranscriptを「status: complete」にする | ファイル有無で正確にstatusを設定 | 嘘の状態は後から追跡不能 |
| 元ファイルを移動・削除する | コピーして元は保持 | 元データを壊すと取り返しがつかない |
| transcript_ts.jsonがないのにエラーにする | statusを「partial」にして続行 | タイムスタンプは必須ではない |
| レベル選択を提示せずにナレッジ抽出を開始 | Bundle完了後にレベル選択を提示 | ユーザーが処理範囲を決める |
| スキル化プランを承認なしで実行 | プラン提示→承認→実行 | 不要なスキルを生成しない |
| リソース取得失敗でパイプライン全体を止める | skippedにして続行 | リソース取得はbest-effort |
| ページテキストがないことをエラーにする | 取得できたものだけ保存 | 動画のみの章もある |
| extractorをopusで実行 | sonnetで実行 | コスト3-5倍差 |
| 1 Agentで全章順次処理 | 章を分割してN Agent並列 | 速度N倍 |
| マニュアル生成後にレビュー省略 | image-alignment.mdに従ってレビュー | 整合性検証必須 |
| skill-synthesizerが ~/.claude/skills/ に直接書く | staging dir経由でcp | Write権限ブロック対策 |
| 全章完了してからまとめてレビュー | 先行1章でサンプルレビュー | 手戻りコスト最小化 |
| サンプルレビューで並列処理を止める | 並列継続しつつ確認 | 速度を犠牲にしない |
| 30秒間隔でスクショ抽出(--no-phash) | pHashモード(デフォルト)で抽出 | スライド見落とし・重複を防げる |
---
セルフ検証チェックリスト
処理完了時に以下を確認:
Phase A(Bundle作成)
- [ ] manifest.json が存在し、JSON として valid
- [ ] manifest.json の全 transcript_path が実在ファイルを指す
- [ ] manifest.json の全 screenshots_dir が実在ディレクトリを指す
- [ ] 各章の screenshot_count が実際のファイル数と一致
- [ ] status が各章の実態を正確に反映(complete / partial / pending)
- [ ] 元ファイルが変更・削除されていない
- [ ] page_text.md / links.json は取得できた章のみ存在
- [ ] resources-manifest.json が存在し、各エントリのstatusが正確
Phase B(レベル別処理)
- [ ] ユーザーが選択したレベルの処理が全て完了
- [ ] Level 2+: 全章のmanual.mdが生成されている
- [ ] Level 3: スキル化プランがユーザー承認済み
- [ ] Level 3: 承認されたスキルのSKILL.mdが生成されている
---
必要環境
- Python 3
- ffmpeg(動画処理時)
- GROQ_API_KEY(文字起こし時、
.envファイルに保存) - Chrome + Claude in Chrome拡張(UTAGE/Udemy URL時)
依存スキル・スクリプト
| 用途 | パス |
|---|---|
| HLSダウンロード | ~/.claude/skills/utage-manual/scripts/hls_downloader.py |
| 文字起こし | ~/.claude/skills/utage-manual/scripts/transcribe.py |
| スクリーンショット抽出 | ~/.claude/skills/utage-manual/scripts/screenshot_extractor.py |
| YouTubeダウンロード | video-downloader スキル |
| 正規化 | ~/.claude/skills/manabi-ingest/scripts/normalize_bundle.py |
| ナレッジ抽出 | concept-extractor スキル |
| 画像分類 | visual-indexer スキル |
| 手順抽出 | procedure-extractor スキル |
| マニュアル生成 | utage-manual スキル |
| スキル化プラン | skill-planner スキル |
| スキル生成 | skill-synthesizer スキル |
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Course Bundle Manifest",
"description": "course-ingestスキルが生成するcourse-bundleのマニフェストファイルのスキーマ",
"type": "object",
"required": ["course_name", "source_type", "language", "chapters"],
"properties": {
"course_name": {
"type": "string",
"description": "講座名"
},
"source_type": {
"type": "string",
"enum": ["utage", "youtube", "local"],
"description": "入力ソースの種類"
},
"source_url": {
"type": "string",
"format": "uri",
"description": "ソースURL(utage/youtubeの場合)"
},
"language": {
"type": "string",
"default": "ja",
"description": "言語コード(ISO 639-1)"
},
"speaker": {
"type": "string",
"description": "講師名"
},
"chapters": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "title", "transcript_path", "screenshots_dir", "screenshot_count", "status"],
"properties": {
"id": {
"type": "string",
"description": "章ID(ゼロパディング: '00', '01', ...)",
"pattern": "^[0-9a-zA-Z_-]+$"
},
"title": {
"type": "string",
"description": "章タイトル"
},
"duration_seconds": {
"type": ["integer", "null"],
"minimum": 0,
"description": "動画の長さ(秒)。不明な場合はnull"
},
"transcript_path": {
"type": "string",
"description": "transcriptファイルへの相対パス(manifest.jsonからの相対)"
},
"transcript_ts_path": {
"type": ["string", "null"],
"description": "タイムスタンプ付きtranscript JSONへの相対パス。存在しない場合はnull"
},
"screenshots_dir": {
"type": "string",
"description": "スクリーンショットディレクトリへの相対パス"
},
"screenshot_count": {
"type": "integer",
"minimum": 0,
"description": "スクリーンショットの枚数"
},
"manual_path": {
"type": ["string", "null"],
"description": "生成済みマニュアルへの相対パス。未生成の場合はnull"
},
"status": {
"type": "string",
"enum": ["complete", "partial", "pending"],
"description": "章の処理状態。complete=全データ揃い、partial=一部欠損、pending=未処理"
}
}
}
}
}
}
#!/usr/bin/env python3
"""
normalize_bundle.py
utage-manual形式の出力ディレクトリをcourse-bundle標準フォーマットに変換する。
Usage:
python normalize_bundle.py \
--input "/path/to/youtube_booster_manual" \
--output "/path/to/course-bundle" \
--course-name "YouTube Booster" \
--speaker "おさる" \
--source-type "utage"
"""
import argparse
import json
import os
import re
import shutil
import sys
from pathlib import Path
def detect_chapters(input_dir: Path) -> list[dict]:
"""入力ディレクトリからチャプター情報を検出する。
transcripts/, screenshots/, manuals/ のファイル名からチャプターIDを抽出し、
統合したチャプターリストを返す。
"""
chapters = {}
# transcripts/ から検出
transcripts_dir = input_dir / "transcripts"
if transcripts_dir.is_dir():
for f in sorted(transcripts_dir.iterdir()):
if f.suffix == ".txt" and f.name != "CLAUDE.md":
chapter_id = f.stem # e.g., "03", "12-1"
if chapter_id not in chapters:
chapters[chapter_id] = {"id": chapter_id, "title": ""}
chapters[chapter_id]["has_transcript"] = True
chapters[chapter_id]["transcript_source"] = str(f)
# screenshots/ から検出
screenshots_dir = input_dir / "screenshots"
if screenshots_dir.is_dir():
for d in sorted(screenshots_dir.iterdir()):
if d.is_dir():
chapter_id = d.name
if chapter_id not in chapters:
chapters[chapter_id] = {"id": chapter_id, "title": ""}
jpg_files = list(d.glob("*.jpg")) + list(d.glob("*.png"))
chapters[chapter_id]["has_screenshots"] = True
chapters[chapter_id]["screenshot_count"] = len(jpg_files)
chapters[chapter_id]["screenshots_source"] = str(d)
# manuals/ からタイトルを抽出
manuals_dir = input_dir / "manuals"
if manuals_dir.is_dir():
for f in sorted(manuals_dir.iterdir()):
if f.suffix == ".md" and f.name != "CLAUDE.md":
# ファイル名パターン: "03_タイトル.md" or "03.md"
match = re.match(r"^([0-9a-zA-Z_-]+?)_(.+)\.md$", f.name)
if match:
chapter_id = match.group(1)
title = match.group(2)
else:
chapter_id = f.stem
title = ""
if chapter_id not in chapters:
chapters[chapter_id] = {"id": chapter_id, "title": ""}
if title:
chapters[chapter_id]["title"] = title
chapters[chapter_id]["has_manual"] = True
chapters[chapter_id]["manual_source"] = str(f)
# タイトルが空の場合、transcriptの先頭行から推測
for ch_id, ch in chapters.items():
if not ch.get("title") and ch.get("transcript_source"):
try:
with open(ch["transcript_source"], "r", encoding="utf-8") as f:
first_line = f.readline().strip()
if first_line:
ch["title"] = first_line[:80]
except Exception:
pass
if not ch.get("title"):
ch["title"] = f"Chapter {ch_id}"
# 実質的なコンテンツがないチャプターを除外
filtered = {
k: v for k, v in chapters.items()
if v.get("has_transcript") or v.get("has_manual") or v.get("screenshot_count", 0) > 0
}
return sorted(filtered.values(), key=lambda c: c["id"])
def estimate_duration(transcript_path: str) -> int | None:
"""transcriptの文字数から動画の長さを推定する。
日本語の場合、1分あたり約300-400文字として推定。
"""
try:
with open(transcript_path, "r", encoding="utf-8") as f:
text = f.read()
char_count = len(text)
# 1分あたり350文字として推定
estimated_minutes = char_count / 350
return int(estimated_minutes * 60)
except Exception:
return None
def create_bundle(
input_dir: Path,
output_dir: Path,
course_name: str,
speaker: str,
source_type: str,
source_url: str | None,
language: str,
) -> dict:
"""course-bundleを作成する。"""
chapters_data = detect_chapters(input_dir)
if not chapters_data:
print("ERROR: チャプターが検出されませんでした。", file=sys.stderr)
print(f" 入力ディレクトリ: {input_dir}", file=sys.stderr)
print(
" transcripts/, screenshots/, manuals/ のいずれかが必要です。",
file=sys.stderr,
)
sys.exit(1)
# 出力ディレクトリ作成
chapters_out = output_dir / "chapters"
chapters_out.mkdir(parents=True, exist_ok=True)
manifest_chapters = []
for ch in chapters_data:
ch_id = ch["id"]
ch_dir = chapters_out / ch_id
ch_dir.mkdir(exist_ok=True)
# transcript をコピー
transcript_path = None
if ch.get("has_transcript") and ch.get("transcript_source"):
src = Path(ch["transcript_source"])
dst = ch_dir / "transcript.txt"
shutil.copy2(src, dst)
transcript_path = f"chapters/{ch_id}/transcript.txt"
# タイムスタンプ付きJSONがあれば一緒にコピー
ts_src = src.with_suffix(".json")
if not ts_src.exists():
ts_src = src.parent / f"{src.stem}_ts.json"
ts_path = None
if ts_src.exists():
ts_dst = ch_dir / "transcript_ts.json"
shutil.copy2(ts_src, ts_dst)
ts_path = f"chapters/{ch_id}/transcript_ts.json"
else:
transcript_path = f"chapters/{ch_id}/transcript.txt"
ts_path = None
# screenshots をコピー
ss_count = 0
ss_dir_rel = f"chapters/{ch_id}/screenshots/"
if ch.get("has_screenshots") and ch.get("screenshots_source"):
src_dir = Path(ch["screenshots_source"])
dst_dir = ch_dir / "screenshots"
if dst_dir.exists():
shutil.rmtree(dst_dir)
shutil.copytree(src_dir, dst_dir)
ss_count = ch.get("screenshot_count", 0)
else:
(ch_dir / "screenshots").mkdir(exist_ok=True)
# manual をコピー
manual_path = None
if ch.get("has_manual") and ch.get("manual_source"):
src = Path(ch["manual_source"])
dst = ch_dir / "manual.md"
shutil.copy2(src, dst)
manual_path = f"chapters/{ch_id}/manual.md"
# duration推定
duration = None
if ch.get("transcript_source"):
duration = estimate_duration(ch["transcript_source"])
# status判定
has_transcript = ch.get("has_transcript", False)
has_screenshots = ch.get("has_screenshots", False) and ss_count > 0
if has_transcript and has_screenshots:
status = "complete"
elif has_transcript or has_screenshots:
status = "partial"
else:
status = "pending"
manifest_chapters.append(
{
"id": ch_id,
"title": ch["title"],
"duration_seconds": duration,
"transcript_path": transcript_path,
"transcript_ts_path": ts_path,
"screenshots_dir": ss_dir_rel,
"screenshot_count": ss_count,
**({"manual_path": manual_path} if manual_path else {}),
"status": status,
}
)
# manifest.json 生成
manifest = {
"course_name": course_name,
"source_type": source_type,
**({"source_url": source_url} if source_url else {}),
"language": language,
"speaker": speaker,
"chapters": manifest_chapters,
}
manifest_path = output_dir / "manifest.json"
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)
return manifest
def validate_manifest(output_dir: Path) -> bool:
"""manifest.jsonのパスが全て実在するか検証する。"""
manifest_path = output_dir / "manifest.json"
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = json.load(f)
all_valid = True
for ch in manifest["chapters"]:
for key in ["transcript_path", "screenshots_dir"]:
rel_path = ch.get(key)
if rel_path:
full_path = output_dir / rel_path
if not full_path.exists():
print(f"MISSING: {full_path}", file=sys.stderr)
all_valid = False
ts_path = ch.get("transcript_ts_path")
if ts_path:
full_path = output_dir / ts_path
if not full_path.exists():
print(f"WARNING (optional): {full_path} not found", file=sys.stderr)
ss_dir = ch.get("screenshots_dir")
if ss_dir:
full_path = output_dir / ss_dir
if full_path.exists():
actual_count = len(
list(full_path.glob("*.jpg")) + list(full_path.glob("*.png"))
)
expected = ch.get("screenshot_count", 0)
if actual_count != expected:
print(
f"WARNING: {ch['id']} screenshot_count mismatch: "
f"expected={expected}, actual={actual_count}",
file=sys.stderr,
)
return all_valid
def main():
parser = argparse.ArgumentParser(
description="utage-manual形式の出力をcourse-bundleフォーマットに正規化する"
)
parser.add_argument(
"--input",
required=True,
help="入力ディレクトリ(utage-manual出力形式)",
)
parser.add_argument(
"--output",
required=True,
help="出力ディレクトリ(course-bundle)",
)
parser.add_argument(
"--course-name",
required=True,
help="講座名",
)
parser.add_argument(
"--speaker",
default="",
help="講師名",
)
parser.add_argument(
"--source-type",
choices=["utage", "youtube", "udemy", "loom", "local"],
default="utage",
help="入力ソースの種類",
)
parser.add_argument(
"--source-url",
default=None,
help="ソースURL(任意)",
)
parser.add_argument(
"--language",
default="ja",
help="言語コード(デフォルト: ja)",
)
args = parser.parse_args()
input_dir = Path(args.input).resolve()
output_dir = Path(args.output).resolve()
if not input_dir.is_dir():
print(f"ERROR: 入力ディレクトリが存在しません: {input_dir}", file=sys.stderr)
sys.exit(1)
if output_dir.exists() and any(output_dir.iterdir()):
print(f"WARNING: 出力ディレクトリが空ではありません: {output_dir}", file=sys.stderr)
response = input("上書きしますか? (y/N): ").strip().lower()
if response != "y":
print("中断しました。")
sys.exit(0)
print(f"入力: {input_dir}")
print(f"出力: {output_dir}")
print(f"講座名: {args.course_name}")
print(f"講師: {args.speaker}")
print(f"ソース: {args.source_type}")
print()
# チャプター検出
chapters = detect_chapters(input_dir)
print(f"検出チャプター数: {len(chapters)}")
for ch in chapters:
parts = []
if ch.get("has_transcript"):
parts.append("transcript")
if ch.get("has_screenshots"):
parts.append(f"screenshots({ch.get('screenshot_count', 0)})")
if ch.get("has_manual"):
parts.append("manual")
print(f" {ch['id']}: {ch['title'][:40]} [{', '.join(parts)}]")
print()
# Bundle作成
manifest = create_bundle(
input_dir=input_dir,
output_dir=output_dir,
course_name=args.course_name,
speaker=args.speaker,
source_type=args.source_type,
source_url=args.source_url,
language=args.language,
)
# バリデーション
print("manifest.json バリデーション中...")
if validate_manifest(output_dir):
print("✓ 全パス正常")
else:
print("✗ 一部パスに問題があります", file=sys.stderr)
sys.exit(1)
# サマリー
complete = sum(1 for ch in manifest["chapters"] if ch["status"] == "complete")
partial = sum(1 for ch in manifest["chapters"] if ch["status"] == "partial")
pending = sum(1 for ch in manifest["chapters"] if ch["status"] == "pending")
print()
print("=== Course Bundle 作成完了 ===")
print(f" 出力先: {output_dir}")
print(f" チャプター数: {len(manifest['chapters'])}")
print(f" complete: {complete}, partial: {partial}, pending: {pending}")
print(f" manifest: {output_dir / 'manifest.json'}")
if __name__ == "__main__":
main()