Last active
April 21, 2026 17:02
-
-
Save chezou/60f258e045433d2d430f89566e803bfe to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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