Parse an ElevenLabs Studio chapter content JSON (the chapter endpoint XHR)
into a normalized, pipeline-ready dialog block list.
The chapter endpoint returns the full script content for a single chapter
GET /v1/studio/projects/{project_id}/chapters/{chapter_id}/content
(or equivalent — captured via DevTools Network tab)
Usage
python parse_xi_chapter.py [-o output.json]
python parse_xi_chapter.py -p [-o output.json]
python parse_xi_chapter.py -p --episode S02E03 [-o output.json]
Output schema
{
"chapter": { meta },
"episode": "S02E03", # if --episode provided
"stats": { block counts, conversion status },
"blocks": [
{
"index": 0, # sequential position in script
"block_id": "...",
"order": "a0", # ElevenLabs sort key
"voice_id": "6wrQFQPr...",
"character": "ADAM", # resolved if project JSON provided
"text": "It's 7:14 AM...",
"is_converted": false,
"duration_ms": null, # populated if block was generated
"muted": false,
"volume_gain_db": 0.0,
"last_change_unix_ms": ...,
"last_converted_at_unix_ms": null
},
...
]
}
load_voice_map(project_path: str) -> dict
Load voice_id -> character name from a project JSON.
Uses base_voices name as the character label.
Returns empty dict if project_path is None.
Source code in tools/parse_xi_chapter.py
| def load_voice_map(project_path: str) -> dict:
"""
Load voice_id -> character name from a project JSON.
Uses base_voices name as the character label.
Returns empty dict if project_path is None.
"""
if not project_path:
return {}
p = Path(project_path)
if not p.exists():
print(f"WARNING: project file {p} not found, skipping voice resolution",
file=sys.stderr)
return {}
with p.open() as f:
project = json.load(f)
# If it's already a parsed output from parse_xi_project.py
if "voice_map" in project:
return {vid: v["name"] for vid, v in project["voice_map"].items()}
# Raw XHR project JSON
base = {v["voice_id"]: v.get("name", "") for v in project.get("base_voices", [])}
return base
|
normalize_block(block: dict, index: int, voice_map: dict) -> dict
Source code in tools/parse_xi_chapter.py
| def normalize_block(block: dict, index: int, voice_map: dict) -> dict:
children = block.get("children", [])
# All observed blocks have exactly one tts_node child
child = children[0] if children else {}
voice_id = child.get("settings", {}).get("project_voice_ref_id", "") or ""
character = voice_map.get(voice_id, "") if voice_map else ""
# duration_ms: from tts_element if block was generated
tts_element = child.get("tts_element") or {}
duration_ms = tts_element.get("duration_ms") # None if not yet generated
return {
"index": index,
"block_id": block.get("block_id"),
"order": block.get("order"),
"track_id": block.get("track_id"),
"sub_type": block.get("sub_type"),
"voice_id": voice_id,
"character": character,
"text": (child.get("text") or "").strip(),
"is_converted": block.get("is_converted", False),
"duration_ms": duration_ms,
"muted": child.get("muted", False),
"volume_gain_db": child.get("volume_gain_db", 0.0),
"fade_in_ms": child.get("fade_in_ms", 0),
"fade_out_ms": child.get("fade_out_ms", 0),
"regeneration_count": block.get("regeneration_count", 0),
"last_change_unix_ms": block.get("last_change_unix_ms"),
"last_converted_at_unix_ms": block.get("last_converted_at_unix_ms"),
}
|
normalize_chapter_meta(chapter: dict) -> dict
Source code in tools/parse_xi_chapter.py
| def normalize_chapter_meta(chapter: dict) -> dict:
return {
"chapter_id": chapter.get("chapter_id"),
"name": chapter.get("name"),
"state": chapter.get("state"),
"can_be_downloaded": chapter.get("can_be_downloaded", False),
"has_video": chapter.get("has_video", False),
"last_conversion_date_unix": chapter.get("last_conversion_date_unix"),
"last_message_at_ms": chapter.get("last_message_at_ms"),
}
|
build_stats(blocks: list) -> dict
Source code in tools/parse_xi_chapter.py
| def build_stats(blocks: list) -> dict:
total = len(blocks)
converted = sum(1 for b in blocks if b["is_converted"])
has_duration = sum(1 for b in blocks if b["duration_ms"] is not None)
total_duration_ms = sum(b["duration_ms"] for b in blocks if b["duration_ms"])
chars_by_voice = {}
lines_by_character = {}
for b in blocks:
vid = b["voice_id"]
char = b["character"] or vid[:8]
chars_by_voice[char] = chars_by_voice.get(char, 0) + len(b["text"])
lines_by_character[char] = lines_by_character.get(char, 0) + 1
return {
"total_blocks": total,
"converted_blocks": converted,
"unconverted_blocks": total - converted,
"blocks_with_duration": has_duration,
"total_duration_ms": total_duration_ms,
"total_duration_tc": _ms_to_tc(total_duration_ms),
"total_chars": sum(len(b["text"]) for b in blocks),
"estimated_runtime_min": round(sum(len(b["text"]) for b in blocks) / 776, 1),
"lines_by_character": dict(sorted(lines_by_character.items(),
key=lambda x: -x[1])),
"chars_by_voice": dict(sorted(chars_by_voice.items(),
key=lambda x: -x[1])),
}
|
parse_chapter(chapter: dict, voice_map: dict, episode: str = None) -> dict
Source code in tools/parse_xi_chapter.py
| def parse_chapter(chapter: dict, voice_map: dict, episode: str = None) -> dict:
content = chapter.get("content", {})
raw_blocks = content.get("blocks", [])
blocks = [
normalize_block(b, i, voice_map)
for i, b in enumerate(raw_blocks)
]
result = {
"chapter": normalize_chapter_meta(chapter),
"blocks": blocks,
"stats": build_stats(blocks),
}
if episode:
result["episode"] = episode
return result
|
main
Source code in tools/parse_xi_chapter.py
| def main():
parser = argparse.ArgumentParser(
description="Parse ElevenLabs chapter content JSON into normalized dialog blocks"
)
parser.add_argument("input",
help="Path to chapter content JSON (XHR capture)")
parser.add_argument("-p", "--project",
help="Path to project JSON (from parse_xi_project or raw XHR) "
"for character name resolution",
default=None)
parser.add_argument("--episode",
help="Episode tag to embed in output (e.g. S02E03)",
default=None)
parser.add_argument("-o", "--output",
help="Output JSON path (default: stdout)")
args = parser.parse_args()
src = Path(args.input)
if not src.exists():
print(f"ERROR: {src} not found", file=sys.stderr)
sys.exit(1)
with src.open() as f:
chapter = json.load(f)
voice_map = load_voice_map(args.project)
result = parse_chapter(chapter, voice_map, episode=args.episode)
out_json = json.dumps(result, indent=2)
if args.output:
Path(args.output).write_text(out_json)
print(f"Written to {args.output}")
else:
print(out_json)
|