Claude Code ログを自動で Qiita 記事化する仕組みを作ったら、設計書の前提が3つ間違っていた話
はじめに
前回の記事で、qiitto(Claude API × Qiita API の半自動記事生成ツール)の v1.0 実装記録を公開しました。
今回は v1.1 として「Claude Code セッションログ自動取込」機能を追加しました。Claude Code で開発したプロジェクトの .jsonl ログを qiitto に取り込み、そのままAI記事化まで持っていく機能です。
そして本記事自体、qiitto v1.1 の取込機能で生成・公開しています。qiitto で qiitto の機能拡張を記事化するメタの2乗循環。設計段階からこれを狙っていました。
実装してみると、設計書(自分で書いた仕様書です)の前提が 3箇所ピンポイントで間違っていました。いずれも公式ドキュメントにも Claude の API リファレンスにも記載がなく、実際にコードを動かして初めて分かる類のものです。その3点を中心に記録します。
やったこと
機能の全体像
[ローカルの ~/.claude/projects/*/xxxx.jsonl]
↓ GET /api/sources/claude-projects(プロジェクト一覧取得)
↓ POST /api/sources/from-claude-log/preview(取込プレビュー)
↓ POST /api/sources/from-claude-log(sources テーブルへ永続化)
↓ POST /api/generations(Claude API で記事生成)
↓ /drafts/[id](編集 → Qiita 同期 → 公開)
実装したファイルは主に4つです。
| ファイル | 役割 |
|---|---|
backend/app/services/claude_log_reader.py |
JSONL ストリームパーサ本体 |
backend/app/api/sources.py(追記) |
3エンドポイント追加 |
backend/app/schemas.py(追記) |
リクエスト/レスポンス3型 |
frontend/components/SourceInput.tsx(改修) |
取込UI(タブ追加) |
ハマったポイント
① message.content がブロック配列だった(設計書は文字列前提)
設計書にはこう書いていました。
// 設計書の想定(簡略仕様)
{"type":"user", "message": {"content": "ユーザーの発言テキスト"}}
{"type":"assistant", "message": {"content": [{"type":"text","text":"Claude の応答"}]}}
「user は文字列、assistant はブロック配列」という非対称な構造を想定していたのですが、実際のログを Python で解析すると:
import json
with open("real_session.jsonl") as f:
for line in f:
row = json.loads(line)
if row.get("type") == "user":
content = row["message"]["content"]
print(type(content), repr(content[:80]) if isinstance(content, str) else content[0])
出力:
<class 'list'> {'type': 'tool_result', 'tool_use_id': 'toolu_01...', 'content': '...'}
<class 'list'> {'type': 'text', 'text': '設計書を読みました。Day 1(基盤構築)を実行します。'}
<class 'str'> 'あなたは Cotton-Web 山田英紀の...' # まれにこのパターンも
user の content も配列でした。しかも中には tool_result(ツール実行結果)、<task-notification> や <ide_opened_file> といった内部合成タグを持つブロックが大量に混入しています。これらを素材に含めてしまうと、記事生成プロンプトが数万トークンのノイズだらけになります。
対処として、ブロックを種別で振り分けて合成タグを除外しました。
import json
import re
from typing import Union
_SYNTHETIC_TAG_RE = re.compile(
r"<(task-notification|ide_opened_file|ide_selection|"
r"system-reminder|search-results)[^>]*>",
re.IGNORECASE,
)
def _extract_text(content: Union[str, list]) -> str:
"""content が文字列でも配列でも扱えるように正規化して本文テキストを返す"""
if isinstance(content, str):
text = content
else:
parts = []
for block in content:
if not isinstance(block, dict):
continue
btype = block.get("type", "")
if btype == "text":
parts.append(block.get("text", ""))
# tool_result / tool_use / thinking / image は除外
text = "\n".join(parts)
# Claude Code が自動挿入する合成タグを除去
text = _SYNTHETIC_TAG_RE.sub("", text)
return text.strip()
② プロジェクトパスのエンコードが不可逆で、別パスが衝突する
設計書には「絶対パスの / を - に置換してディレクトリ名になる」と書いていました。
/Volumes/英紀HD/開発/qiitto → -Volumes-英紀HD-開発-qiitto
実際の ~/.claude/projects/ を ls してみると:
-Volumes---HD--------
-Volumes---HD---------1
-Volumes---HD---------2
日本語や記号もすべて - に置換されていました。英紀HD・開発・ソース がそれぞれ - の塊になり、複数の実際のプロジェクトが --HD-------- という同じパターンに潰れています。末尾 -1 -2 が衝突回避のサフィックスです。
つまり「エンコードされたディレクトリ名からもとのパスを逆算することは不可能」です。
解決策は「逆算をやめて、JSONL 各行の cwd フィールドを読んで突合する」ことでした。
def _find_project_dir(project_path: str) -> Path | None:
"""
~/.claude/projects/ 配下を走査し、
JSONL の cwd フィールドが project_path に一致するディレクトリを返す。
エンコードは不可逆なので逆算はしない。
"""
base = Path.home() / ".claude" / "projects"
if not base.exists():
return None
for candidate in base.iterdir():
if not candidate.is_dir():
continue
# 最新ファイル1件だけ先読みして cwd を確認(高速化)
jsonl_files = sorted(candidate.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
for jf in jsonl_files[:1]:
try:
with open(jf, encoding="utf-8") as f:
for line in f:
row = json.loads(line)
if row.get("cwd") == project_path:
return candidate
break # 先頭1行で判定
except (json.JSONDecodeError, OSError):
continue
return None
③ クライアントから受け取ったパスをファイルとして開いてはいけない(Path Traversal 対策)
「project_path のオーナー検証」という要件があり、最初は「パスが ~/.claude/projects/ 配下かチェックする」案を考えました。でもそれだと ../../../etc/passwd を渡されたときに検証をすり抜けるケースがあります。
安全な設計は「クライアントの project_path をファイルパスとして一切使わず、文字列の比較にのみ使う」ことです。
@router.post("/from-claude-log/preview")
async def preview_claude_log(
body: ClaudeLogRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> ClaudeLogPreviewOut:
# body.project_path は open() や Path() に渡さない
# _find_project_dir() 内で cwd 比較にのみ使用する
proj_dir = _find_project_dir(body.project_path)
if proj_dir is None:
raise AppError(
code="CLAUDE_LOG_NOT_FOUND",
message=f"プロジェクト '{body.project_path}' のログが見つかりません",
status_code=404,
)
# proj_dir は自前の解決結果 → base 配下チェックで念押し
base = Path.home() / ".claude" / "projects"
try:
proj_dir.resolve().relative_to(base.resolve())
except ValueError:
raise AppError(code="CLAUDE_LOG_NOT_FOUND", message="不正なパスです", status_code=404)
result = read_claude_sessions(proj_dir, since=body.since, include_tool_calls=body.include_tool_calls)
return ClaudeLogPreviewOut(**result)
proj_dir はサーバー自身が ~/.claude/projects/ を走査して決定したパスなので、クライアントの入力値に引きずられません。resolve().relative_to() で .. 抜けやシンボリックリンク経由の脱出も阻止しています。
ストリーム処理の実装
数MBの JSONL を json.load() で一括展開するとメモリを圧迫するため、1行ずつストリーム処理しています。max_chars で上限を設けて途中打ち切りも実装。
def read_claude_sessions(
proj_dir: Path,
since: str = "last_session",
include_tool_calls: bool = False,
max_chars: int = 200_000,
) -> dict:
jsonl_files = _select_files(proj_dir, since)
sections: list[str] = []
total_chars = 0
truncated = False
for jf in jsonl_files:
session_id = jf.stem
lines = []
with open(jf, encoding="utf-8") as f: # ← ストリーム読み
for raw in f: # 1行ずつ処理
if not raw.strip():
continue
try:
row = json.loads(raw) # ← json.load ではなく json.loads
except json.JSONDecodeError:
continue
chunk = _row_to_markdown(row, include_tool_calls)
if not chunk:
continue
total_chars += len(chunk)
if total_chars > max_chars:
truncated = True
break
lines.append(chunk)
if lines:
ts = _session_timestamp(jf)
sections.append(f"# === Session {session_id[:8]} ({ts}) ===\n\n" + "\n\n".join(lines))
if truncated:
break
return {
"content": "\n\n---\n\n".join(sections),
"session_count": len(sections),
"char_count": total_chars,
"truncated": truncated,
...
}
実機での動作確認
ローカルスタック(Postgres:5433 / backend:8100 / frontend:3100)で実際に動かした結果です。
# プロジェクト一覧取得
GET /api/sources/claude-projects
→ 18プロジェクト列挙(各プロジェクトのセッション数・最終更新付き)
# qiitto 開発ログのプレビュー
POST /api/sources/from-claude-log/preview
{"project_path": "/Volumes/英紀HD/開発/ココナラ", "since": "last_session"}
→ 85,618文字 / User:16発言 / Claude:308発言
# 永続化
POST /api/sources/from-claude-log
→ 201, source_id 返却
# 生成
POST /api/generations
→ status=succeeded, tokens_used=5,359, draft_id 生成
生成された記事のタイトル案の一つが「Claude Code ログを自動で Qiita 記事化する仕組みを作ったら、設計書の前提が3つ間違っていた話」でした。そのままこの記事のタイトルに使っています。
取込プレビュー UI
フロントエンドでは /generate の「Claude Codeログ」タブに以下を実装しました。
- プロジェクト一覧のドロップダウン(
~/.claude/projects/を自動列挙) - 取込期間ラジオ(直近1セッション / 過去24h / 過去7日 / カスタム)
- 含める内容チェック(User発言 / Claude応答 / ツール呼出)
- 「取込プレビュー」→ 統計表示+テキストエリアに整形素材を流し込み
- 「この内容でAI生成」→ 通常の生成フローへ接続
素材が長すぎる場合はテキストエリア上で手動削除できるようにしており、プレビュー後に「どの部分を記事にするか」を人間が判断できる設計です。
パーサの単体テスト
実際の JSONL 形式に合わせてサンプルを作り、11件のテストを書きました。
def test_extract_text_list_with_synthetic_tags():
"""合成タグが混入したブロック配列から本文だけ抽出できること"""
content = [
{"type": "tool_result", "tool_use_id": "x", "content": "tool output"},
{"type": "text", "text": "<task-notification>...</task-notification>本文テキスト"},
]
result = _extract_text(content)
assert "tool output" not in result
assert "task-notification" not in result
assert "本文テキスト" in result
def test_cwd_matching_returns_correct_dir(tmp_path):
"""cwd フィールドで正しいプロジェクトディレクトリを特定できること"""
proj_dir = tmp_path / "projects" / "-Volumes---HD--------"
proj_dir.mkdir(parents=True)
jf = proj_dir / "session.jsonl"
jf.write_text(
json.dumps({"type": "user", "cwd": "/Volumes/英紀HD/開発/qiitto",
"message": {"content": "hi"}, "timestamp": "2026-05-25T07:00:00Z"})
+ "\n"
)
# モンキーパッチで Path.home() を差し替え
with patch("services.claude_log_reader.Path.home", return_value=tmp_path):
result = _find_project_dir("/Volumes/英紀HD/開発/qiitto")
assert result == proj_dir
全 42 テスト(既存 31 + 新規 11)green を確認しています。
学び
設計書の前提が間違っていたという話でしたが、もう少し正確に言うと「Claude Code の内部フォーマットは公開仕様ではないため、実際に動かすまで確認できなかった」です。
個人の感想として、こういった「動かして初めて分かる」系の情報こそ記事にする価値があると思っています。公式ドキュメントには載らず、Stack Overflow にも出ないが、同じ実装をしようとした人が必ずハマるポイント。
3点まとめると:
-
message.contentはユーザー発言も含めブロック配列(文字列は旧形式またはレアケース)。isinstanceでガードを入れて両方吸収する。 -
プロジェクトパスのエンコードは不可逆。ディレクトリ名からパスを復元しようとせず、JSONL 内の
cwdフィールドで突合する。 - クライアントのパスをファイルとして開かない。文字列比較にのみ使い、実際に開くパスはサーバーが自ら解決する。
本プロダクト qiitto は Cotton-Web 屋号で運営している
自社プロダクト群の5本目です:posutto / tubetto / tradepostpro / keiri / qiitto
次回(第3弾)は、Day 1 で踏んだ
「Docker Desktop を使わず colima + Docker CLI で PostgreSQL 16 を立てた話」
を予定しています。
本記事は qiitto v1.1 の「Claude Codeログ取込」機能で生成した素材を元に、軽微な編集を加えて公開しています。