Skip to content

Instantly share code, notes, and snippets.

@chezou
Last active April 21, 2026 17:02
Show Gist options
  • Select an option

  • Save chezou/60f258e045433d2d430f89566e803bfe to your computer and use it in GitHub Desktop.

Select an option

Save chezou/60f258e045433d2d430f89566e803bfe to your computer and use it in GitHub Desktop.
#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.10"
# dependencies = ["bleach>=6.0", "markdown>=3.5"]
# ///
"""
chat2html.py — Claude の会話ログを静的 HTML に変換する統合スクリプト。
## 対応入力形式(自動判定)
1. **claude.ai エクスポート** (conversations.json / .jsonl)
- Settings → Privacy → Export data でダウンロードできるZIP内のファイル
- 複数会話が入っているので一覧/検索/番号指定で選択する
2. **Claude Code セッション** (~/.claude/projects/<proj>/*.jsonl)
- type/uuid/sessionId を持つ行ベースのログ
- ツール使用履歴(Bash/Read/Edit/Agent など)も全部表示
- thinking ブロックと長い tool_result は <details> で折りたたみ
3. **claude-chat-exporter.js の Markdown** (.md)
- https://github.com/agarwalvishal/claude-chat-exporter
- `## Human (日付):` / `## Claude:` 形式
## 使い方
# 自動判定で単純変換(Claude Code JSONL / Markdown)
uv run chat2html.py session.jsonl
uv run chat2html.py conversation.md
uv run chat2html.py session.jsonl -o out.html
# claude.ai エクスポート: 一覧表示
uv run chat2html.py conversations.json
# claude.ai エクスポート: タイトル検索
uv run chat2html.py conversations.json -s "API"
# claude.ai エクスポート: 番号指定で変換
uv run chat2html.py conversations.json -i 0,3,7 -o out/
# claude.ai エクスポート: 全変換
uv run chat2html.py conversations.json --all -o out/
# 複数ファイル一括(Markdown / Claude Code JSONL)
uv run chat2html.py a.md b.jsonl -d out/
"""
import argparse
import bleach
import html as html_mod
import json
import os
import re
import sys
from datetime import datetime
from typing import Any
import markdown
# ─── HTML テンプレート ──────────────────────────────────────────────
HTML_TEMPLATE = """\
<!DOCTYPE html>
<html lang="{html_lang}" data-theme="auto">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>
@import url('{font_url}');
/* ── ダーク (default) ── */
:root, :root[data-theme="dark"] {{
--bg: #0f0f0f;
--surface: #1a1a1a;
--human-bg: #1e293b;
--human-border: #3b82f6;
--assistant-bg: #1a1a1a;
--assistant-border: #525252;
--text: #e4e4e7;
--text-muted: #a1a1aa;
--accent: #60a5fa;
--code-bg: #0d0d0d;
--border: #2a2a2a;
--toggle-bg: #1a1a1a;
--toggle-border: #2a2a2a;
--tool-bg: #141414;
--tool-border: #2a2a2a;
--tool-accent: #a78bfa;
--thinking-accent: #f59e0b;
}}
/* ── ライト ── */
:root[data-theme="light"] {{
--bg: #fafafa;
--surface: #ffffff;
--human-bg: #eff6ff;
--human-border: #3b82f6;
--assistant-bg: #ffffff;
--assistant-border: #d4d4d8;
--text: #18181b;
--text-muted: #71717a;
--accent: #2563eb;
--code-bg: #f4f4f5;
--border: #e4e4e7;
--toggle-bg: #ffffff;
--toggle-border: #e4e4e7;
--tool-bg: #fafaf9;
--tool-border: #e4e4e7;
--tool-accent: #7c3aed;
--thinking-accent: #d97706;
}}
/* ── auto: OS設定に従う ── */
@media (prefers-color-scheme: light) {{
:root[data-theme="auto"] {{
--bg: #fafafa;
--surface: #ffffff;
--human-bg: #eff6ff;
--human-border: #3b82f6;
--assistant-bg: #ffffff;
--assistant-border: #d4d4d8;
--text: #18181b;
--text-muted: #71717a;
--accent: #2563eb;
--code-bg: #f4f4f5;
--border: #e4e4e7;
--toggle-bg: #ffffff;
--toggle-border: #e4e4e7;
--tool-bg: #fafaf9;
--tool-border: #e4e4e7;
--tool-accent: #7c3aed;
--thinking-accent: #d97706;
}}
}}
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: {body_font};
background: var(--bg);
color: var(--text);
line-height: 1.7;
-webkit-font-smoothing: antialiased;
transition: background 0.2s, color 0.2s;
}}
.theme-toggle {{
position: fixed;
top: 1rem;
right: 1rem;
z-index: 100;
background: var(--toggle-bg);
border: 1px solid var(--toggle-border);
color: var(--text);
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
transition: background 0.2s, border-color 0.2s, transform 0.1s;
padding: 0;
}}
.theme-toggle:hover {{ transform: scale(1.05); }}
.theme-toggle:active {{ transform: scale(0.95); }}
.theme-toggle .icon-sun {{ display: none; }}
.theme-toggle .icon-moon {{ display: block; }}
:root[data-theme="light"] .theme-toggle .icon-sun {{ display: block; }}
:root[data-theme="light"] .theme-toggle .icon-moon {{ display: none; }}
@media (prefers-color-scheme: light) {{
:root[data-theme="auto"] .theme-toggle .icon-sun {{ display: block; }}
:root[data-theme="auto"] .theme-toggle .icon-moon {{ display: none; }}
}}
.header {{
border-bottom: 1px solid var(--border);
padding: 2rem 0;
margin-bottom: 2rem;
}}
.header-inner {{
max-width: 820px;
margin: 0 auto;
padding: 0 1.5rem;
}}
.header h1 {{
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 0.5rem;
}}
.header .meta {{
color: var(--text-muted);
font-size: 0.85rem;
}}
.header .meta span + span::before {{
content: '·';
margin: 0 0.5em;
}}
.conversation {{
max-width: 820px;
margin: 0 auto;
padding: 0 1.5rem 4rem;
}}
.message {{
margin-bottom: 1.5rem;
border-radius: 8px;
border-left: 3px solid transparent;
padding: 1.25rem 1.5rem;
position: relative;
transition: background 0.2s, border-color 0.2s;
}}
.message.human {{
background: var(--human-bg);
border-left-color: var(--human-border);
}}
.message.assistant {{
background: var(--assistant-bg);
border-left-color: var(--assistant-border);
}}
.message-role {{
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin-bottom: 0.6rem;
display: flex;
align-items: center;
gap: 0.4rem;
}}
.message.human .message-role {{ color: var(--accent); }}
.message-role .timestamp {{
font-weight: 400;
letter-spacing: 0;
text-transform: none;
margin-left: auto;
font-size: 0.7rem;
}}
.message-body {{ font-size: 0.95rem; }}
.message-body p {{ margin-bottom: 0.8em; }}
.message-body p:last-child {{ margin-bottom: 0; }}
.message-body ul, .message-body ol {{
padding-left: 1.5em;
margin-bottom: 0.8em;
}}
.message-body li {{ margin-bottom: 0.3em; }}
.message-body h1, .message-body h2, .message-body h3,
.message-body h4, .message-body h5, .message-body h6 {{
margin: 1.2em 0 0.5em;
font-weight: 700;
letter-spacing: -0.01em;
}}
.message-body h1 {{ font-size: 1.3rem; }}
.message-body h2 {{ font-size: 1.15rem; }}
.message-body h3 {{ font-size: 1.05rem; }}
.message-body code {{
font-family: 'JetBrains Mono', monospace;
font-size: 0.85em;
background: var(--code-bg);
padding: 0.15em 0.4em;
border-radius: 4px;
border: 1px solid var(--border);
}}
.message-body pre {{
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1rem 1.25rem;
overflow-x: auto;
margin-bottom: 0.8em;
line-height: 1.5;
}}
.message-body pre code {{
background: none;
border: none;
padding: 0;
font-size: 0.85rem;
}}
.message-body blockquote {{
border-left: 3px solid var(--border);
padding-left: 1em;
color: var(--text-muted);
margin-bottom: 0.8em;
}}
.message-body table {{
border-collapse: collapse;
width: 100%;
margin-bottom: 0.8em;
font-size: 0.9rem;
}}
.message-body th, .message-body td {{
border: 1px solid var(--border);
padding: 0.5em 0.75em;
text-align: left;
}}
.message-body th {{
background: var(--code-bg);
font-weight: 500;
}}
.message-body a {{
color: var(--accent);
text-decoration: none;
}}
.message-body a:hover {{ text-decoration: underline; }}
.message-body hr {{
border: none;
border-top: 1px solid var(--border);
margin: 1.2em 0;
}}
.message-body img {{
max-width: 100%;
border-radius: 6px;
}}
/* ── thinking / tool blocks ── */
details.thinking,
details.tool-call,
details.tool-result-long,
details.pasted {{
margin: 0.6em 0;
border: 1px solid var(--tool-border);
border-radius: 6px;
background: var(--tool-bg);
font-size: 0.88rem;
}}
details.thinking {{ border-left: 3px solid var(--thinking-accent); }}
details.tool-call,
details.tool-result-long {{ border-left: 3px solid var(--tool-accent); }}
details.pasted {{ border-left: 3px solid var(--text-muted); }}
details summary {{
cursor: pointer;
padding: 0.5em 0.9em;
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
color: var(--text-muted);
user-select: none;
display: list-item;
}}
details summary:hover {{ color: var(--text); }}
details summary .badge {{
display: inline-block;
padding: 0.1em 0.5em;
border-radius: 3px;
font-size: 0.7rem;
margin-right: 0.4em;
font-weight: 500;
}}
details.thinking summary .badge {{
background: var(--thinking-accent);
color: #fff;
}}
details.tool-call summary .badge {{
background: var(--tool-accent);
color: #fff;
}}
details.pasted summary .badge {{
background: var(--text-muted);
color: var(--bg);
}}
details summary .tool-name {{
color: var(--text);
font-weight: 500;
}}
details summary .meta {{
color: var(--text-muted);
margin-left: 0.4em;
}}
details[open] summary {{
border-bottom: 1px solid var(--tool-border);
}}
details .body {{
padding: 0.6em 0.9em 0.8em;
}}
details .body pre {{
margin-bottom: 0.5em;
font-size: 0.8rem;
}}
.tool-result-inline {{
margin: 0.4em 0;
padding: 0.5em 0.9em;
background: var(--tool-bg);
border: 1px solid var(--tool-border);
border-left: 3px solid var(--tool-accent);
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
color: var(--text-muted);
white-space: pre-wrap;
word-break: break-word;
}}
.footer {{
text-align: center;
padding: 2rem;
color: var(--text-muted);
font-size: 0.75rem;
border-top: 1px solid var(--border);
}}
</style>
<!-- highlight.js (CDN): ダーク/ライト2種のテーマを読み込み、JSで切替 -->
<link id="hljs-dark"
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/atom-one-dark.min.css">
<link id="hljs-light"
rel="stylesheet"
disabled
href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/atom-one-light.min.css">
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
</head>
<body>
<button class="theme-toggle" id="themeToggle" aria-label="Toggle theme" title="Toggle theme">
<span class="icon-sun">☀</span>
<span class="icon-moon">☾</span>
</button>
<div class="header">
<div class="header-inner">
<h1>{title}</h1>
<div class="meta">
{meta_items}
</div>
</div>
</div>
<div class="conversation">
{messages}
</div>
<div class="footer">
{footer_text}
</div>
<script>
(function() {{
var root = document.documentElement;
var btn = document.getElementById('themeToggle');
var KEY = 'claude-chat-theme';
var hljsDark = document.getElementById('hljs-dark');
var hljsLight = document.getElementById('hljs-light');
function applyHljsTheme() {{
// 現在適用されているテーマを判定 (data-theme + 必要なら prefers-color-scheme)
var theme = root.getAttribute('data-theme');
var effectiveDark;
if (theme === 'light') {{
effectiveDark = false;
}} else if (theme === 'dark') {{
effectiveDark = true;
}} else {{
// auto
effectiveDark = !window.matchMedia('(prefers-color-scheme: light)').matches;
}}
if (hljsDark) hljsDark.disabled = !effectiveDark;
if (hljsLight) hljsLight.disabled = effectiveDark;
}}
try {{
var saved = localStorage.getItem(KEY);
if (saved === 'light' || saved === 'dark') {{
root.setAttribute('data-theme', saved);
}}
}} catch (e) {{}}
// OS テーマ変更に追従(data-theme="auto" のとき)
if (window.matchMedia) {{
var mq = window.matchMedia('(prefers-color-scheme: light)');
var listener = function() {{ applyHljsTheme(); }};
if (mq.addEventListener) mq.addEventListener('change', listener);
else if (mq.addListener) mq.addListener(listener);
}}
applyHljsTheme();
btn.addEventListener('click', function() {{
var current = root.getAttribute('data-theme');
var next;
if (current === 'auto') {{
var prefersLight = window.matchMedia('(prefers-color-scheme: light)').matches;
next = prefersLight ? 'dark' : 'light';
}} else {{
next = current === 'light' ? 'dark' : 'light';
}}
root.setAttribute('data-theme', next);
try {{ localStorage.setItem(KEY, next); }} catch (e) {{}}
applyHljsTheme();
}});
// highlight.js をすべての <pre><code> に適用
if (window.hljs) {{
document.querySelectorAll('pre code').forEach(function(block) {{
window.hljs.highlightElement(block);
}});
}}
}})();
</script>
</body>
</html>
"""
# ─── 多言語対応 (i18n) ──────────────────────────────────────────
TRANSLATIONS: dict[str, dict[str, str]] = {
"ja": {
"html_lang": "ja",
"font_url": "https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap",
"body_font": "'Noto Sans JP', -apple-system, sans-serif",
"footer_text": "Exported from claude.ai / Claude Code",
"role_you": "You",
"role_claude": "Claude",
"messages_label": "messages",
"default_title_md": "Conversation with Claude",
"default_title_cc": "Claude Code Session",
"default_conv_title": "Untitled",
"cli_loaded": "読み込み: {n} 件の会話 ({path})",
"cli_header_row": "{h:>4} {m:>8} {c:>16} Title",
"cli_hint": "変換するには -i <番号> または --all を指定してください。",
"cli_out_of_range": " スキップ: #{idx} は範囲外です(0-{last})",
"cli_err_no_conv": "エラー: 会話が見つかりませんでした。",
"cli_err_not_found": "エラー: ファイルが見つかりません: {path}",
"cli_err_invalid_index": "エラー: -i には数字をカンマ区切りで指定してください。",
"cli_err_claudeai_format": "{path} は claude.ai エクスポート形式です。 -i / --all / -s を使ってください。",
"cli_err_unsupported": "未対応の形式: {fmt}",
"cli_done": "完了!",
"omitted_badge": "omitted",
"omitted_result_label": "結果は共有のため省略(--full で表示)",
"omitted_input_label": "入力は共有のため省略(--full で表示)",
"pasted_badge": "pasted",
"pasted_summary": "{lines} 行 / {chars:,} 文字 を貼り付け",
},
"en": {
"html_lang": "en",
"font_url": "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap",
"body_font": "'Inter', -apple-system, sans-serif",
"footer_text": "Exported from claude.ai / Claude Code",
"role_you": "You",
"role_claude": "Claude",
"messages_label": "messages",
"default_title_md": "Conversation with Claude",
"default_title_cc": "Claude Code Session",
"default_conv_title": "Untitled",
"cli_loaded": "Loaded {n} conversations ({path})",
"cli_header_row": "{h:>4} {m:>8} {c:>16} Title",
"cli_hint": "Use -i <indices> or --all to convert.",
"cli_out_of_range": " Skipping #{idx}: out of range (0-{last})",
"cli_err_no_conv": "Error: no conversations found.",
"cli_err_not_found": "Error: file not found: {path}",
"cli_err_invalid_index": "Error: -i requires comma-separated integers.",
"cli_err_claudeai_format": "{path} is a claude.ai export. Use -i / --all / -s.",
"cli_err_unsupported": "Unsupported format: {fmt}",
"cli_done": "Done!",
"omitted_badge": "omitted",
"omitted_result_label": "Result omitted for sharing (use --full to show)",
"omitted_input_label": "Input omitted for sharing (use --full to show)",
"pasted_badge": "pasted",
"pasted_summary": "{lines} lines / {chars:,} chars pasted",
},
}
# 現在の言語。main() で --lang の値に設定される。
_LANG: str = "ja"
# tool_result とセンシティブな tool_use 入力を表示するか。
# デフォルト(False)はシェア安全モード。--full で True になる。
_FULL: bool = False
# セーフモードでも表示してよい tool_use のフィールド
# (ツールの意図を説明する description 系のみ)
SAFE_TOOL_USE_FIELDS = ("description", "subject", "subagent_type")
def t(key: str, **kwargs) -> str:
"""翻訳された文字列を取得。kwargs で format 展開。"""
s = TRANSLATIONS[_LANG].get(key, key)
return s.format(**kwargs) if kwargs else s
# ─── 共通ユーティリティ ──────────────────────────────────────────
# URL全体を捉える正規表現
# 末尾の句読点や閉じ括弧はURLに含めないよう除外
_URL_RE = re.compile(r"https?://[^\s<>\"'`\\]+")
# クエリ/フラグメントにこれらのパラメータ名があれば OAuth 系と判定
_OAUTH_QUERY_KEYS = (
"state", "code", "access_token", "id_token", "refresh_token",
"client_secret", "code_challenge", "code_verifier",
"redirect_uri", "authorization_code",
)
# パスやホスト名にこれらが含まれれば OAuth 系と判定
_OAUTH_PATH_MARKERS = (
"/oauth", "/authorize", "/authenticate", "/callback",
"/auth/", "/login/oauth", "/o/oauth",
)
# 既知の認証プロバイダのホスト名
_OAUTH_HOSTS = (
"accounts.google.com",
"login.microsoftonline.com",
"login.windows.net",
"auth0.com",
"okta.com",
"login.salesforce.com",
)
def _is_oauth_url(url: str) -> bool:
"""URL が OAuth 認証フロー関連かを判定。"""
lower = url.lower()
# クエリ/フラグメントに OAuth パラメータがあるか
# シンプルに文字列含有で判定 (正確な URL パースはしない)
qidx = lower.find("?")
fidx = lower.find("#")
boundary = min(
i for i in (qidx, fidx, len(lower)) if i >= 0
) if (qidx >= 0 or fidx >= 0) else len(lower)
query_part = lower[boundary:]
if query_part:
for key in _OAUTH_QUERY_KEYS:
# "?key=" or "&key=" or "#key=" のパターン
if f"?{key}=" in query_part or f"&{key}=" in query_part or f"#{key}=" in query_part:
return True
# パス/ホスト部分のマーカー
host_and_path = lower[:boundary]
for marker in _OAUTH_PATH_MARKERS:
if marker in host_and_path:
return True
# 既知のプロバイダドメイン
for host in _OAUTH_HOSTS:
if host in host_and_path:
return True
return False
# マスク時の置き換え文字列
_URL_REDACTED = "[redacted OAuth URL]"
_ALLOWED_HTML_TAGS = set(bleach.sanitizer.ALLOWED_TAGS) | {
"p", "br", "pre", "code",
"h1", "h2", "h3", "h4", "h5", "h6",
"table", "thead", "tbody", "tr", "th", "td",
"blockquote", "hr", "img",
}
_ALLOWED_HTML_ATTRIBUTES = {
"a": ["href", "title"],
"code": ["class"],
"img": ["src", "alt", "title"],
}
_ALLOWED_HTML_PROTOCOLS = {"http", "https", "mailto"}
def _mask_oauth_urls(text: str) -> str:
"""文字列中の OAuth 系 URL を [redacted OAuth URL] に置換する。
常に有効(意図的な情報漏洩防止)。
"""
if not text:
return text
# URL 末尾に付きがちな句読点(日本語/英文両対応)
_TRAILING_PUNCT = set(".,;:!?)]}>」』'\"")
def _replace(m: re.Match) -> str:
url = m.group(0)
# URL 末尾にありがちな句読点は URL の外に出す
trailing = ""
while url and url[-1] in _TRAILING_PUNCT:
trailing = url[-1] + trailing
url = url[:-1]
if _is_oauth_url(url):
return _URL_REDACTED + trailing
return url + trailing
return _URL_RE.sub(_replace, text)
def _render_md(text: str) -> str:
if not text:
return ""
text = _mask_oauth_urls(text)
rendered = markdown.markdown(
text, extensions=["fenced_code", "tables", "nl2br"]
)
return bleach.clean(
rendered,
tags=_ALLOWED_HTML_TAGS,
attributes=_ALLOWED_HTML_ATTRIBUTES,
protocols=_ALLOWED_HTML_PROTOCOLS,
strip=True,
)
def _render_code(text: str, lang: str = "") -> str:
text = _mask_oauth_urls(text or "")
escaped = html_mod.escape(text)
cls = f' class="language-{lang}"' if lang else ""
return f"<pre><code{cls}>{escaped}</code></pre>"
# fenced code block を識別する正規表現
# ``` から ``` までを1単位として捕捉する。tilde fence もおまけで対応。
_FENCED_CODE_RE = re.compile(
r"(?ms)^(?P<fence>```|~~~)[^\n]*\n.*?^(?P=fence)\s*$"
)
def _render_user_md(text: str) -> str:
"""user メッセージ用の Markdown レンダリング。
コードブロック外の長い塊(貼り付け)は <details> で折りたたむ。
判定は、コードブロック外のテキストを空行で分割したサブチャンク単位で行う。
これにより、ユーザーが書いた地の文と貼り付けデータが混在していても、
貼り付け部分だけを折りたたむことができる。
"""
if not text:
return ""
# コードブロックを一時的にプレースホルダへ退避
placeholders: list[str] = []
def _stash(m: re.Match) -> str:
placeholders.append(m.group(0))
return f"\x00PASTE_STASH_{len(placeholders) - 1}\x00"
stashed = _FENCED_CODE_RE.sub(_stash, text)
# コードブロック外部分を、プレースホルダを境にチャンク分割
parts = re.split(r"(\x00PASTE_STASH_\d+\x00)", stashed)
rendered: list[str] = []
for part in parts:
if not part:
continue
pm = re.fullmatch(r"\x00PASTE_STASH_(\d+)\x00", part)
if pm:
# コードブロックはそのまま Markdown で描画
rendered.append(_render_md(placeholders[int(pm.group(1))]))
continue
# コード外チャンクを、空行で区切られたサブチャンクに分割
# 各サブチャンクを独立に評価: 長ければ畳む、短ければ通常描画
subchunks = re.split(r"(\n\s*\n+)", part)
for sub in subchunks:
if not sub:
continue
# 区切り部分(空行のみ)は素通し
if re.fullmatch(r"\n\s*\n+", sub):
rendered.append(sub)
continue
trimmed = sub.strip("\n")
line_count = trimmed.count("\n") + 1 if trimmed else 0
char_count = len(trimmed)
if trimmed and (
line_count > USER_PASTE_LINES or char_count > USER_PASTE_CHARS
):
summary_label = t(
"pasted_summary", lines=line_count, chars=char_count
)
escaped = html_mod.escape(_mask_oauth_urls(trimmed))
rendered.append(
f'<details class="pasted">'
f'<summary><span class="badge">{html_mod.escape(t("pasted_badge"))}</span>'
f'<span class="meta">{html_mod.escape(summary_label)}</span></summary>'
f'<div class="body"><pre>{escaped}</pre></div>'
f'</details>'
)
else:
rendered.append(_render_md(sub))
return "".join(rendered)
def _short_preview(text: str, maxlen: int = 60) -> str:
s = (text or "").replace("\n", " ").strip()
s = _mask_oauth_urls(s)
if len(s) > maxlen:
s = s[:maxlen] + "…"
return html_mod.escape(s)
def _format_timestamp(ts_str: str) -> str:
"""ISO 8601 タイムスタンプを読みやすい形式に。"""
if not ts_str:
return ""
try:
dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
return dt.strftime("%Y-%m-%d %H:%M")
except Exception:
return ts_str
def _sanitize_filename(name: str) -> str:
if not name:
return "untitled"
safe = re.sub(r'[<>:"/\\|?*]', "", name)
safe = re.sub(r"\s+", "_", safe.strip())
safe = safe[:120]
return safe or "untitled"
# ─── 形式判定 ────────────────────────────────────────────────────
FORMAT_CC_JSONL = "cc_jsonl" # Claude Code セッション
FORMAT_CLAUDEAI = "claudeai" # claude.ai export (複数会話)
FORMAT_MD = "md" # claude-chat-exporter Markdown
def detect_format(path: str, text: str) -> str:
"""拡張子と内容から形式を判定。"""
ext = os.path.splitext(path)[1].lower()
# 拡張子で先に判別
if ext in (".md", ".markdown"):
return FORMAT_MD
stripped = text.lstrip()
if not stripped:
return FORMAT_MD
# JSONL or JSON の中身を見る
first_line = stripped.split("\n", 1)[0].strip()
# まず JSON 全体としてパース可能か
if stripped.startswith("["):
try:
arr = json.loads(stripped)
if isinstance(arr, list) and arr and isinstance(arr[0], dict):
if "chat_messages" in arr[0]:
return FORMAT_CLAUDEAI
except json.JSONDecodeError:
pass
# 1行ずつJSONとして見る
if first_line.startswith("{"):
try:
obj = json.loads(first_line)
if isinstance(obj, dict):
if "chat_messages" in obj:
return FORMAT_CLAUDEAI
# Claude Code の特徴: type, uuid, sessionId
if "sessionId" in obj or (
"type" in obj and "uuid" in obj
):
return FORMAT_CC_JSONL
# さらに先のレコードを見て判定
for line in stripped.split("\n")[:20]:
line = line.strip()
if not line:
continue
try:
o = json.loads(line)
except json.JSONDecodeError:
continue
if "chat_messages" in o:
return FORMAT_CLAUDEAI
if "sessionId" in o or (
"type" in o and "uuid" in o
):
return FORMAT_CC_JSONL
except json.JSONDecodeError:
pass
# 見出し判定
if re.search(r"^##\s+(Human|Claude)", text, re.MULTILINE):
return FORMAT_MD
return FORMAT_MD
# ─── Markdown パーサ ─────────────────────────────────────────────
MD_HEADER_RE = re.compile(
r"^##\s+(Human|Claude)(?:\s*\(([^)]+)\))?\s*:\s*$",
re.MULTILINE,
)
def parse_markdown(md_text: str) -> tuple[str, str, list[dict]]:
"""(title, created, messages) を返す。"""
title = t("default_title_md")
m = re.search(r"^#\s+(.+?)\s*$", md_text, re.MULTILINE)
if m:
title = m.group(1).strip()
messages = []
matches = list(MD_HEADER_RE.finditer(md_text))
created = ""
for i, match in enumerate(matches):
role_raw = match.group(1)
timestamp = match.group(2) or ""
role = "human" if role_raw == "Human" else "assistant"
start = match.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(md_text)
body = md_text[start:end]
body = re.sub(r"\n---\s*\n*\s*$", "\n", body).strip()
if not body:
continue
if not created and timestamp and role == "human":
created = timestamp
messages.append({
"role": role,
"timestamp": timestamp,
"body_html": _render_user_md(body) if role == "human" else _render_md(body),
})
return title, created, messages
# ─── claude.ai エクスポートパーサ ────────────────────────────────
def load_claudeai_export(text: str) -> list[dict]:
"""JSON配列 / JSONL 両対応で読み込む。"""
text = text.strip()
try:
data = json.loads(text)
if isinstance(data, list):
return data
if isinstance(data, dict):
return [data]
except json.JSONDecodeError:
pass
conversations = []
for line in text.splitlines():
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
conversations.append(obj)
except json.JSONDecodeError:
continue
return conversations
def _extract_claudeai_message_text(msg: dict) -> str:
if "content" in msg and isinstance(msg["content"], list):
parts = []
for block in msg["content"]:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict):
if block.get("type") == "text":
parts.append(block.get("text", ""))
if parts:
return "\n\n".join(parts)
if msg.get("text"):
return msg["text"]
return ""
def parse_claudeai_conversation(conv: dict) -> tuple[str, str, list[dict]]:
"""claude.ai export の1つの会話をパース。"""
title = conv.get("name") or t("default_conv_title")
created = _format_timestamp(conv.get("created_at", ""))
messages = []
for msg in conv.get("chat_messages", []):
sender = msg.get("sender", "unknown")
role = "human" if sender == "human" else "assistant"
ts = _format_timestamp(msg.get("created_at", ""))
text = _extract_claudeai_message_text(msg)
if not text.strip():
continue
messages.append({
"role": role,
"timestamp": ts,
"body_html": _render_user_md(text) if role == "human" else _render_md(text),
})
return title, created, messages
# ─── Claude Code JSONL パーサ ───────────────────────────────────
LONG_OUTPUT_THRESHOLD = 500
# user メッセージ内の「貼り付け」と判定する閾値
# (行数または文字数のどちらかを超えたら折りたたむ)
USER_PASTE_LINES = 50
USER_PASTE_CHARS = 2000
SLASH_COMMAND_RE = re.compile(r"^\s*<command-name>", re.DOTALL)
LOCAL_CAVEAT_RE = re.compile(r"^\s*<local-command-caveat>", re.DOTALL)
def _stringify_tool_result_content(content: Any) -> str:
if content is None:
return ""
if isinstance(content, str):
return _mask_oauth_urls(content)
if isinstance(content, list):
parts = []
for block in content:
if not isinstance(block, dict):
parts.append(str(block))
continue
btype = block.get("type")
if btype == "text":
parts.append(block.get("text", ""))
elif btype == "tool_reference":
parts.append(f"- {block.get('tool_name', '')}")
else:
parts.append(json.dumps(block, ensure_ascii=False, indent=2))
return _mask_oauth_urls("\n\n".join(p for p in parts if p))
return _mask_oauth_urls(json.dumps(content, ensure_ascii=False, indent=2))
def _render_thinking_block(thinking_text: str) -> str:
if not thinking_text.strip():
return ""
preview = _short_preview(thinking_text, 80)
body_html = _render_md(thinking_text)
return (
f'<details class="thinking">'
f'<summary><span class="badge">thinking</span>'
f'<span class="meta">{preview}</span></summary>'
f'<div class="body">{body_html}</div>'
f'</details>'
)
def _render_tool_use_block(tool_use: dict, tool_result: dict | None) -> str:
name = tool_use.get("name", "tool")
tinput = tool_use.get("input", {}) if isinstance(tool_use.get("input"), dict) else {}
# summary 用のプレビュー生成
# セーフモードでは description 系フィールドのみ参照。
# --full では従来通り広範な候補から拾う。
preview = ""
if _FULL:
candidate_keys = (
"command", "description", "query", "subject",
"file_path", "path", "subagent_type", "prompt", "pattern",
)
else:
candidate_keys = SAFE_TOOL_USE_FIELDS
for key in candidate_keys:
if key in tinput and tinput[key]:
preview = _short_preview(str(tinput[key]), 80)
break
if _FULL and not preview and tinput:
try:
first_key = next(iter(tinput))
preview = _short_preview(str(tinput[first_key]), 80)
except StopIteration:
pass
# body 部: input と result
body_parts: list[str] = []
if _FULL:
# 入力の全貌
input_pretty = json.dumps(tinput, ensure_ascii=False, indent=2)
body_parts.append(_render_code(input_pretty, "json"))
# tool_result の全貌
if tool_result is not None:
result_str = _stringify_tool_result_content(tool_result.get("content"))
if result_str:
if len(result_str) > LONG_OUTPUT_THRESHOLD:
inner_preview = _short_preview(result_str, 80)
rendered = _render_md(result_str)
body_parts.append(
f'<details class="tool-result-long" open>'
f'<summary><span class="badge">result</span>'
f'<span class="meta">{len(result_str):,} chars — '
f'{inner_preview}</span></summary>'
f'<div class="body">{rendered}</div>'
f'</details>'
)
else:
body_parts.append(
f'<div class="tool-result-inline">'
f'{html_mod.escape(result_str)}'
f'</div>'
)
else:
# ── セーフモード ──
# tool_use の入力は description 系のみ表示
safe_input = {
k: tinput[k]
for k in SAFE_TOOL_USE_FIELDS
if k in tinput and tinput[k]
}
if safe_input:
input_pretty = json.dumps(safe_input, ensure_ascii=False, indent=2)
body_parts.append(_render_code(input_pretty, "json"))
else:
body_parts.append(
f'<div class="tool-result-inline">'
f'{html_mod.escape(t("omitted_input_label"))}'
f'</div>'
)
# tool_result は常に省略メッセージのみ
if tool_result is not None:
body_parts.append(
f'<div class="tool-result-inline">'
f'<span class="badge" style="background:var(--text-muted);'
f'color:var(--bg);">{html_mod.escape(t("omitted_badge"))}</span> '
f'{html_mod.escape(t("omitted_result_label"))}'
f'</div>'
)
summary = (
f'<summary><span class="badge">tool</span>'
f'<span class="tool-name">{html_mod.escape(name)}</span>'
f'<span class="meta">{preview}</span></summary>'
)
return (
f'<details class="tool-call">'
f'{summary}'
f'<div class="body">{"".join(body_parts)}</div>'
f'</details>'
)
def parse_cc_jsonl(jsonl_text: str) -> tuple[str, str, list[dict]]:
"""Claude Code セッションJSONLをパース。"""
records = []
for line in jsonl_text.splitlines():
line = line.strip()
if not line:
continue
try:
records.append(json.loads(line))
except json.JSONDecodeError:
continue
# tool_use_id → tool_result のマップ
tool_results_by_id: dict[str, dict] = {}
for rec in records:
if rec.get("type") != "user":
continue
msg = rec.get("message") or {}
content = msg.get("content")
if isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_result":
tid = block.get("tool_use_id")
if tid:
tool_results_by_id[tid] = block
messages: list[dict] = []
assistant_accum: dict[str, dict] = {}
first_user_prompt = ""
first_timestamp = ""
session_id = ""
for rec in records:
rtype = rec.get("type")
if not rtype:
continue
session_id = session_id or rec.get("sessionId", "")
if not first_timestamp:
first_timestamp = rec.get("timestamp", "")
if rec.get("isMeta"):
continue
if rtype in ("file-history-snapshot", "last-prompt", "attachment"):
continue
if rtype == "system":
continue
if rtype == "user":
msg = rec.get("message") or {}
content = msg.get("content")
timestamp = rec.get("timestamp", "")
if isinstance(content, list):
if all(
isinstance(b, dict) and b.get("type") == "tool_result"
for b in content
):
continue
if isinstance(content, str):
if SLASH_COMMAND_RE.match(content) or LOCAL_CAVEAT_RE.match(content):
continue
if not first_user_prompt:
first_user_prompt = content
messages.append({
"role": "human",
"timestamp": timestamp,
"body_html": _render_user_md(content),
})
elif isinstance(content, list):
text_parts = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
text_parts.append(block.get("text", ""))
elif isinstance(block, str):
text_parts.append(block)
text = "\n\n".join(p for p in text_parts if p)
if text:
if not first_user_prompt:
first_user_prompt = text
messages.append({
"role": "human",
"timestamp": timestamp,
"body_html": _render_user_md(text),
})
elif rtype == "assistant":
msg = rec.get("message") or {}
mid = msg.get("id") or rec.get("uuid")
timestamp = rec.get("timestamp", "")
content = msg.get("content") or []
if mid not in assistant_accum:
assistant_accum[mid] = {
"timestamp": timestamp,
"blocks": [],
"emitted_index": None,
}
entry = assistant_accum[mid]
entry["blocks"].extend(content if isinstance(content, list) else [])
parts = []
for block in entry["blocks"]:
if not isinstance(block, dict):
continue
btype = block.get("type")
if btype == "thinking":
thinking_text = block.get("thinking", "")
if thinking_text:
parts.append(_render_thinking_block(thinking_text))
elif btype == "text":
text = block.get("text", "")
if text:
parts.append(_render_md(text))
elif btype == "tool_use":
tid = block.get("id")
tresult = tool_results_by_id.get(tid) if tid else None
parts.append(_render_tool_use_block(block, tresult))
body_html = "\n".join(parts)
if not body_html.strip():
continue
if entry["emitted_index"] is None:
messages.append({
"role": "assistant",
"timestamp": timestamp,
"body_html": body_html,
})
entry["emitted_index"] = len(messages) - 1
else:
messages[entry["emitted_index"]]["body_html"] = body_html
messages[entry["emitted_index"]]["timestamp"] = timestamp
# タイトル生成
title = t("default_title_cc")
if first_user_prompt:
preview = first_user_prompt.strip().split("\n")[0]
if len(preview) > 60:
preview = preview[:60] + "…"
title = preview
elif session_id:
title = f"Session {session_id[:8]}"
created = _format_timestamp(first_timestamp)
return title, created, messages
# ─── レンダリング ────────────────────────────────────────────────
def render_message(msg: dict) -> str:
role_class = msg["role"]
role_label = t("role_you") if role_class == "human" else t("role_claude")
timestamp = html_mod.escape(msg.get("timestamp", ""))
body_html = msg["body_html"]
ts_html = f'<span class="timestamp">{timestamp}</span>' if timestamp else ""
return (
f'<div class="message {role_class}">\n'
f' <div class="message-role">{role_label}{ts_html}</div>\n'
f' <div class="message-body">{body_html}</div>\n'
f'</div>'
)
def to_html(title: str, created: str, messages: list[dict]) -> str:
messages_html = "\n".join(render_message(m) for m in messages)
meta_parts = []
if created:
meta_parts.append(f'<span>{html_mod.escape(created)}</span>')
meta_parts.append(
f'<span>{len(messages)} {html_mod.escape(t("messages_label"))}</span>'
)
meta_items = "\n ".join(meta_parts)
return HTML_TEMPLATE.format(
html_lang=t("html_lang"),
font_url=t("font_url"),
body_font=t("body_font"),
footer_text=html_mod.escape(t("footer_text")),
title=html_mod.escape(title),
meta_items=meta_items,
messages=messages_html,
)
# ─── claude.ai 一覧表示 ─────────────────────────────────────────
def print_claudeai_list(conversations: list, query: str | None = None):
print()
print(t("cli_header_row", h="#", m="Messages", c="Created"))
print("─" * 70)
for i, conv in enumerate(conversations):
title = conv.get("name") or f"({t('default_conv_title')})"
created = _format_timestamp(conv.get("created_at", ""))
n_msgs = len(conv.get("chat_messages", []))
if query and query.lower() not in title.lower():
continue
print(f"{i:>4} {n_msgs:>8} {created:>16} {title}")
print()
# ─── ファイル単位の変換 ─────────────────────────────────────────
def convert_single_file(input_path: str, output_path: str) -> None:
"""Claude Code JSONL / Markdown の単一会話を1つのHTMLに変換。"""
with open(input_path, "r", encoding="utf-8") as f:
text = f.read()
fmt = detect_format(input_path, text)
if fmt == FORMAT_MD:
title, created, messages = parse_markdown(text)
elif fmt == FORMAT_CC_JSONL:
title, created, messages = parse_cc_jsonl(text)
elif fmt == FORMAT_CLAUDEAI:
# claude.ai export は複数会話なので、このパスを通るのは
# 呼び出し側のミス(handle_claudeai_export を使うべき)
raise RuntimeError(t("cli_err_claudeai_format", path=input_path))
else:
raise RuntimeError(t("cli_err_unsupported", fmt=fmt))
html = to_html(title, created, messages)
os.makedirs(
os.path.dirname(os.path.abspath(output_path)) or ".", exist_ok=True
)
with open(output_path, "w", encoding="utf-8") as f:
f.write(html)
print(f" ✓ {input_path} → {output_path} ({fmt}, {len(messages)} msgs)")
def handle_claudeai_export(
input_path: str,
text: str,
args: argparse.Namespace,
) -> None:
"""claude.ai エクスポートを処理(一覧/検索/選択変換)。"""
conversations = load_claudeai_export(text)
if not conversations:
print(t("cli_err_no_conv"), file=sys.stderr)
sys.exit(1)
print(t("cli_loaded", n=len(conversations), path=input_path))
# 一覧 or 検索モード
if args.search or (not args.index and not args.all):
print_claudeai_list(conversations, args.search)
if not args.index and not args.all:
print(t("cli_hint"))
return
# 変換対象の決定
if args.all:
indices = list(range(len(conversations)))
else:
try:
indices = [int(x.strip()) for x in args.index.split(",")]
except ValueError:
print(t("cli_err_invalid_index"), file=sys.stderr)
sys.exit(1)
outdir = args.outdir or args.output or "."
# -o がファイル名っぽい場合(.html)かつ1件のみ → ファイル直指定
single_file_out = None
if args.output and args.output.endswith(".html") and len(indices) == 1:
single_file_out = args.output
else:
os.makedirs(outdir, exist_ok=True)
for idx in indices:
if idx < 0 or idx >= len(conversations):
print(t("cli_out_of_range", idx=idx, last=len(conversations) - 1))
continue
conv = conversations[idx]
title, created, messages = parse_claudeai_conversation(conv)
if single_file_out:
filepath = single_file_out
else:
filename = _sanitize_filename(conv.get("name") or t("default_conv_title")) + ".html"
filepath = os.path.join(outdir, filename)
html = to_html(title, created, messages)
os.makedirs(
os.path.dirname(os.path.abspath(filepath)) or ".", exist_ok=True
)
with open(filepath, "w", encoding="utf-8") as f:
f.write(html)
print(f" ✓ #{idx} → {filepath} ({len(messages)} msgs)")
# ─── メイン ─────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Claude 会話ログ → HTML 変換(3形式対応)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
例:
# Claude Code セッション / Markdown(単純変換)
uv run chat2html.py session.jsonl
uv run chat2html.py conversation.md -o out.html
uv run chat2html.py a.md b.jsonl -d out/
# claude.ai エクスポート(複数会話が入っているJSON/JSONL)
uv run chat2html.py conversations.json # 一覧表示
uv run chat2html.py conversations.json -s "API" # 検索
uv run chat2html.py conversations.json -i 0,3,7 -d out/
uv run chat2html.py conversations.json --all -d out/
""",
)
parser.add_argument("files", nargs="+", help=".md / .jsonl / .json ファイル")
parser.add_argument("-o", "--output", help="出力ファイルパス(単一会話時のみ)")
parser.add_argument("-d", "--outdir", help="出力ディレクトリ")
parser.add_argument("-s", "--search", help="タイトル検索(claude.ai export)")
parser.add_argument(
"-i", "--index",
help="変換する会話の番号(claude.ai export、カンマ区切り: 0,2,5)",
)
parser.add_argument(
"--all", action="store_true",
help="全会話を変換(claude.ai export)",
)
parser.add_argument(
"--lang", choices=["ja", "en"], default="ja",
help="出力言語 / output language (default: ja)",
)
parser.add_argument(
"--full", action="store_true",
help=(
"ツール入出力を全て表示(デフォルトは安全のため tool_result を省略、"
"tool_use は description 系フィールドのみ表示)。"
"なお OAuth 関連 URL は --full 指定時でも常にマスクされる。"
),
)
args = parser.parse_args()
# 言語設定を反映
global _LANG, _FULL
_LANG = args.lang
_FULL = args.full
# 存在確認
for fp in args.files:
if not os.path.exists(fp):
print(t("cli_err_not_found", path=fp), file=sys.stderr)
sys.exit(1)
# 1ファイルずつ判定して処理
for input_path in args.files:
with open(input_path, "r", encoding="utf-8") as f:
text = f.read()
fmt = detect_format(input_path, text)
if fmt == FORMAT_CLAUDEAI:
# claude.ai エクスポートは1ファイルずつ独立に処理
handle_claudeai_export(input_path, text, args)
else:
# 単一会話 (Markdown / Claude Code JSONL)
if len(args.files) == 1 and not args.outdir:
output_path = args.output or (
os.path.splitext(input_path)[0] + ".html"
)
else:
outdir = args.outdir or "."
os.makedirs(outdir, exist_ok=True)
base = os.path.splitext(os.path.basename(input_path))[0]
output_path = os.path.join(outdir, base + ".html")
convert_single_file(input_path, output_path)
print(t("cli_done"))
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment