# 人狼戦績 API システム仕様書

## 1. システム構成

### サーバ構成

| | サーバA（非公開・ログ蓄積） | サーバB（公開API） |
|---|---|---|
| 用途 | HTML保存 + 解析 + バックアップ元 | API公開 + 直接スクレイピング |
| ストレージ | 大容量（HTML保存あり） | 小容量（HTML保存なし） |
| DB | PostgreSQL（zinro_meta） | PostgreSQL（zinro_meta） |
| ランタイム | PHP（cron） | Python（Docker） + PHP（cron） |
| 公開 | なし | Nginx → Docker（HTTPS） |

### ネットワーク構成

- フロントエンド → Nginx（443/HTTPS） → Docker コンテナ（8000/HTTP）
- Docker コンテナ → ホスト PostgreSQL（5432、pg_hba.conf で 172.16.0.0/12 許可）
- バッチコンテナは network_mode: host で 127.0.0.1:5432 に直接接続

---

## 2. API エンドポイント一覧

すべて `/api.php?mode=エンドポイント名&パラメータ...` でアクセス。Nginx → Docker（FastAPI）→ PHPCompatMiddleware が mode からパスに変換。

### システム系

| エンドポイント | メソッド | 説明 |
|---|---|---|
| `/health` | GET | ヘルスチェック。`{"status":"ok"}` を返す |
| `/health/db` | GET | DB接続状態・コネクションプール詳細を返す |
| `/ping` | GET | 環境名を返す |

### 認証系

| エンドポイント | メソッド | 説明 |
|---|---|---|
| `/login` | POST | トリップキーで認証。DBにトリップが存在すればJWTを発行 |
| `/logout` | POST | クライアント側トークン破棄用 |
| `/me` | GET | JWTからログイン状態・trip・nameを返す |

### 個人戦績系

| エンドポイント | メソッド | 説明 |
|---|---|---|
| `/personal` | GET | プレイヤー詳細戦績。key_type(trip/name)とqで指定。勝率・配役別・役職別・時間帯別・曜日別・月別推移・カレンダー・共演者・最近の村・wiki・コメント・プロフィール・トリップ変遷を一括返却。**by_class** フィールドで通常配役/カオス配役それぞれの勝率・勝敗サマリーも返す。キャッシュ＋差分マージで高速応答 |
| `/multi_trip` | POST | 複数トリップの基本情報を一括取得 |

### 配役分類別統計（新規）

| エンドポイント | メソッド | 説明 |
|---|---|---|
| `/stats_by_class` | GET | 全体/通常配役/カオス配役のゲーム数・勝数・勝率を返す。ym=all(全期間)、2025-01(月別)、2025(年別) |

### 村・ゲーム系

| エンドポイント | メソッド | 説明 |
|---|---|---|
| `/village_detail` | GET | 特定ゲームの詳細。参加者・役職分布・勝率・発言統計 |
| `/village_recent` | GET | 各村名の最新ゲーム一覧 |

### ランキング系（月別・年別）

すべて ym パラメータ（YYYY-MM or YYYY）で期間指定。バッチキャッシュ使用。

| エンドポイント | 説明 |
|---|---|
| `/monthly_name_rank` | 名前別ゲーム数ランキング |
| `/monthly_trip_rank` | トリップ別ゲーム数ランキング |
| `/monthly_name_win_rank` | 名前別勝率ランキング |
| `/monthly_trip_win_rank` | トリップ別勝率ランキング |
| `/monthly_name_talk_rank` | 名前別発言密度ランキング |
| `/monthly_name_days_rank` | 名前別参加日数ランキング |
| `/monthly_role_win_rank` | 役職別勝率ランキング |
| `/monthly_haiyaku_win_rank` | 配役構成別勝率ランキング |
| `/monthly_village_rank` | 村名別ゲーム数ランキング |
| `/monthly_haiyaku_rank` | 配役構成別ゲーム数ランキング |
| `/monthly_rm_rank` | RM（村建て人）別ゲーム数ランキング |
| `/monthly_tsuiho_rank` | RM追放回数ランキング（RMがゲーム中に荒らしを強制退場させた回数） |

### ランキング系（全期間）

| エンドポイント | 説明 |
|---|---|
| `/room_rank` | 村名別ゲーム数 |
| `/rm_rank` | RM別ゲーム数 |
| `/tsuiho_rank` | RM追放回数（全期間） |
| `/haiyaku_rank` | 配役構成別ゲーム数 |
| `/rating_rank` | レーティングランキング |

### プロフィール系

| エンドポイント | メソッド | 説明 |
|---|---|---|
| `/profile_get` | GET | ログインユーザーのプロフィール取得 |
| `/profile_update` | POST | 自己紹介・X ID更新（要ログイン） |

### Wiki・コメント系

| エンドポイント | メソッド | 説明 |
|---|---|---|
| `/wiki_save` | POST | Wikiページ保存（要ログイン） |
| `/comment_add` | POST | コメント追加（要ログイン） |

### トリップ変遷系

| エンドポイント | メソッド | 説明 |
|---|---|---|
| `/name_chain_list` | GET | 名前→トリップ変遷チェーン一覧 |
| `/trip_chain_rank` | GET | トリップ変遷ランキング（活動期間順） |
| `/trip_chain_detail` | GET | 特定トリップの変遷詳細 |

### その他

| エンドポイント | メソッド | 説明 |
|---|---|---|
| `/months` | GET | ゲームが存在する全YYYY-MMを返す |
| `/notice` | GET | notice.txtのお知らせを返す |

---

## 3. データベース

DB名: zinro_meta / ユーザー: zinro / PostgreSQL 14

### テーブル一覧

#### games — ゲームテーブル
1ゲーム（1村の1回のゲーム）= 1行。1つのlog_idに複数game_indexがありうる。

| カラム | 型 | 説明 |
|---|---|---|
| id | SERIAL PK | 自動採番。バッチのウォーターマーク基準 |
| log_id | INTEGER | zinro.net のログID |
| game_index | INTEGER | 同一log_id内のゲーム連番（0始まり） |
| created_at | BIGINT | ログ作成時刻（Unixタイムスタンプ） |
| started_at | BIGINT | ゲーム開始時刻 |
| finished_at | BIGINT | ゲーム終了時刻 |
| village_name | TEXT | 村名（正規化済み） |
| haiyaku | TEXT | 配役構成名（12A猫、6A等）。不明時は「不明」 |
| haiyaku_class | TEXT | 「通常」or「カオス」。既知27配役が通常、それ以外がカオス |
| rm_name | TEXT | RM（村建て人）の名前 |
| game_time | INTEGER | ゲーム所要時間（秒） |
| wait_time | INTEGER | 開始までの待ち時間（秒） |
| review_time | INTEGER | 感想戦時間（秒） |
| tsuiho_json | TEXT | RMに追放されたプレイヤーのJSON |
| raw_line | TEXT | 元データ互換の生テキスト |

#### players — プレイヤーテーブル
各ゲームの各参加者（観戦者含む）= 1行。

| カラム | 型 | 説明 |
|---|---|---|
| id | SERIAL PK | |
| game_id | INTEGER FK→games | 所属ゲーム |
| log_id | INTEGER | ログID |
| name | TEXT | プレイヤー名 |
| trip | TEXT | トリップ（◆付きハッシュ） |
| role | TEXT | 役職名 |
| win | INTEGER | 勝利=1、敗北=0 |
| talk_count | INTEGER | 昼の発言数 |
| live_days | INTEGER | 生存日数 |
| is_spectator | INTEGER | 観戦者=1、参加者=0 |
| tsuiho_flag | INTEGER | RMに追放された=1、されていない=0。「○○さんを村から追放しました」のメッセージで検出 |
| created_at | BIGINT | レコード作成時刻 |

#### player_profile — プロフィール
trip(PK), self_intro, x_id, x_display, updated_at

#### wiki — Wikiページ
key_type + key_value でユニーク。body に本文。

#### wiki_hist — Wiki編集履歴
key_type, key_value, body, editor_name, editor_trip, created_at

#### comments — コメント
key_type, key_value, body, author_name, author_trip, created_at, deleted

#### rank_cache_json — ランキングキャッシュ
cache_key(PK), scope, ym, kind, key_type, payload(JSON), total, built_at, updated_at

#### player_ratings — レーティング
trip(PK), rating, games, main_name

#### plain_trip_chain_summary — トリップ変遷サマリー
plain_trip(PK), games_total, name_count, first_ts, last_ts, span_seconds, plain_games, fixed_games

#### plain_trip_chain_names — トリップ変遷名前一覧
plain_trip, name, games_total, first_ts, last_ts

#### name_trip_chain — 名前→トリップ変遷チェーン
representative_trip, latest_trip, first_ts, last_ts, games_total, trip_count

#### batch_state — バッチ状態管理
key(PK)="last_processed_game_id", value_int=最後に処理したgames.id

#### personal_stats_cache — 個人戦績キャッシュ
(key_type, key_value)(PK), watermark_game_id, stats_json(3分類集計JSON), game_ids_json, first_ts, last_ts

---

## 4. バッチ処理一覧

| バッチ | サーバ | 言語 | 頻度 | 内容 |
|---|---|---|---|---|
| scrape_batch.php | B | PHP | 10分毎 | zinro.netからログ取得→解析→DB保存 |
| batch.py | B | Python/Docker | 毎朝4時 | ランキング＋個人戦績キャッシュ構築 |
| parse_logs.php | A | PHP | 10分毎 | zhtml/のHTML解析→DB保存 |
| sync_to_b.sh | A→B | bash | 手動 | pg_dump転送（事故復旧時のみ） |

### 4-1. scrape_batch.php
zinro.net の終了ページから最新ログIDを取得し、DB の MAX(log_id)+1 から最新まで順にHTML取得→解析→INSERT。haiyaku_class（通常/カオス）を自動分類。tsuiho_flag は「○○さんを村から追放しました」メッセージで検出。1件2秒sleep。

### 4-2. batch.py
ウォーターマーク方式で差分処理。ランキングキャッシュ（12種×全期間+月+年）+ 配役分類別統計 + 個人戦績キャッシュ（全体/通常/カオスの3分類）を事前計算。初回フル1〜2時間、日次差分は数分。

### 4-3. parse_logs.php
サーバA の zhtml/ 内HTML を解析。DB の既存log_idと比較して未処理分のみ処理。haiyaku_class、tsuiho_flag 対応。

### 4-4. sync_to_b.sh
pg_dump(COPY形式圧縮)→scp→pg_restore。サーバBのキャッシュ系テーブルもTRUNCATEする。

---

## 5. 配役分類

### 通常配役（27種）
夏至配役、ルアー配役、4A、4A占、6A、6B、6C、メチ配役、8CFO、8CNO（おはよーぐる配役）、9D、10A狩、10C猫、11C猫、11A、12A猫、12B、12B背徳猫（ゆきうさ配役）、12W、12R猫FO（ライス配役）、13Z、14D猫、16A、16D猫、17A、18A猫、19D猫

### カオス配役
上記27種に該当しない配役構成、および不明のゲーム。

---

## 6. ビルド手順

### 6-1. サーバA（非公開・解析用）

```bash
# PostgreSQL
sudo apt install -y postgresql postgresql-client php-cli php-curl php-pgsql php-mbstring
sudo -u postgres createuser zinro
sudo -u postgres createdb -O zinro zinro_meta
sudo -u postgres psql -c "ALTER USER zinro PASSWORD 'CHANGE_ME';"
echo "local   zinro_meta   zinro   md5" | sudo tee -a /etc/postgresql/*/main/pg_hba.conf
sudo systemctl reload postgresql

# スキーマ
cd /var/www/html/zinro
psql -U zinro -d zinro_meta -f schema_pg.sql

# 初回全件解析（screen推奨、数時間かかる）
screen -S parse
php parse_logs.php 2>&1 | tee /var/log/zinro-parse-initial.log
# Ctrl+A → D でデタッチ

# 完了確認
psql -U zinro -d zinro_meta -c "SELECT COUNT(*) FROM games; SELECT COUNT(*) FROM players;"
```

### 6-2. サーバB（公開API）

#### PostgreSQL
```bash
sudo apt install -y postgresql postgresql-client php-cli php-curl php-pgsql php-mbstring
sudo -u postgres createuser zinro
sudo -u postgres createdb -O zinro zinro_meta
sudo -u postgres psql -c "ALTER USER zinro PASSWORD 'CHANGE_ME';"

echo "local   zinro_meta   zinro   md5" | sudo tee -a /etc/postgresql/*/main/pg_hba.conf
echo "host  zinro_meta  zinro  172.16.0.0/12  md5" | sudo tee -a /etc/postgresql/*/main/pg_hba.conf
sudo sed -i "s/^#\?listen_addresses.*/listen_addresses = 'localhost,172.17.0.1'/" /etc/postgresql/*/main/postgresql.conf
sudo systemctl restart postgresql

psql -U zinro -d zinro_meta -f schema_pg.sql
```

#### サーバAからデータ投入
```bash
# サーバA側
pg_dump -U zinro -d zinro_meta --table=games --table=players \
    --data-only --format=custom --compress=6 -f /tmp/zinro_data.dump
scp /tmp/zinro_data.dump mit@サーバBのIP:/tmp/

# サーバB側
pg_restore -U zinro -d zinro_meta --data-only --disable-triggers /tmp/zinro_data.dump
psql -U zinro -d zinro_meta -c "
    SELECT setval('games_id_seq', (SELECT COALESCE(MAX(id),0) FROM games));
    SELECT setval('players_id_seq', (SELECT COALESCE(MAX(id),0) FROM players));
    ANALYZE games; ANALYZE players;"
rm /tmp/zinro_data.dump
```

#### Docker
```bash
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
    https://download.docker.com/linux/ubuntu \
    $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
    | sudo tee /etc/apt/sources.list.d/docker.list
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo systemctl enable docker && sudo systemctl start docker
```

#### ファイアウォール
```bash
sudo iptables -I INPUT -s 172.16.0.0/12 -p tcp --dport 5432 -j ACCEPT
sudo apt install -y iptables-persistent
sudo netfilter-persistent save
```

#### アプリ配置・ビルド
```bash
cd /var/www/html/zinro/jinro-api
# server_b/ の全ファイルを配置

cat > .env << EOF
DATABASE_URL=postgresql://zinro:CHANGE_ME@host.docker.internal:5432/zinro_meta
APP_ENV=production
SESSION_SECRET=$(python3 -c "import secrets; print(secrets.token_urlsafe(64))")
EOF

touch notice.txt

docker compose build
docker compose up -d
curl -s http://127.0.0.1:8000/health
```

#### Nginx
既存の HTTPS server ブロック内に追加:
```nginx
location = /api.php {
    proxy_pass http://127.0.0.1:8000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_read_timeout 30s;
}
```
```bash
sudo nginx -t && sudo systemctl reload nginx
```

#### バッチキャッシュ初回構築
```bash
# バックグラウンド実行（1〜2時間）
nohup docker compose run --rm batch python batch.py --full > /var/log/jinro-batch-initial.log 2>&1 &

# 完了確認
docker ps | grep batch    # 表示されなければ完了
tail -20 /var/log/jinro-batch-initial.log
```

#### 動作確認
```bash
curl -s http://127.0.0.1:8000/health
curl -s "https://ドメイン/api.php?mode=ping"
curl -s "https://ドメイン/api.php?mode=stats_by_class&ym=all"
```

### 6-3. コード更新時の再ビルド
```bash
cd /var/www/html/zinro/jinro-api

# api.py / batch.py を差し替え後
docker compose build
docker compose up -d
docker compose logs --tail=20 api   # 正常起動を確認

# scrape_batch.php はファイル差し替えだけで次回cron実行から反映
```

---

## 7. 運用手順

### 7-1. cron 設定（サーバB）

```bash
sudo crontab -e
```

```
# スクレイピング（10分毎）
0,10,20,30,40,50 * * * * cd /var/www/html/zinro/jinro-api && php scrape_batch.php >> /var/log/zinro-scrape.log 2>&1

# キャッシュバッチ（毎朝4時）
0 4 * * * cd /var/www/html/zinro/jinro-api && docker compose run --rm batch >> /var/log/jinro-batch.log 2>&1
```

### 7-2. cron 設定（サーバA）

```bash
crontab -e
```

```
# HTML取得 + 解析（10分毎）
0,10,20,30,40,50 * * * * cd /var/www/html/zinro && php parse_logs.php >> /var/log/zinro-parse.log 2>&1
```

### 7-3. 日常監視コマンド

```bash
# API 稼働確認
curl -s http://127.0.0.1:8000/health

# コンテナ状態
docker compose ps

# 最新ゲーム
psql -U zinro -d zinro_meta -c "SELECT MAX(log_id), TO_TIMESTAMP(MAX(started_at)) AT TIME ZONE 'Asia/Tokyo' FROM games;"

# スクレイピングログ
tail -5 /var/log/zinro-scrape.log

# キャッシュバッチログ
tail -5 /var/log/jinro-batch.log

# DB サイズ
psql -U zinro -d zinro_meta -c "SELECT pg_size_pretty(pg_database_size('zinro_meta'));"
```

### 7-4. バッチ手動実行

```bash
# 差分バッチ
docker compose run --rm batch python batch.py

# フルリビルド
docker compose run --rm batch python batch.py --full

# ランキングのみ
docker compose run --rm batch python batch.py --ranks-only

# 個人戦績のみ
docker compose run --rm batch python batch.py --personal-only
```

### 7-5. トラブルシューティング

| 症状 | 確認 | 対処 |
|---|---|---|
| API無応答 | `docker compose ps` | `docker compose restart api` |
| DB接続エラー | `ss -tlnp \| grep 5432` | `sudo systemctl start postgresql` |
| スクレイピング停止 | `tail /var/log/zinro-scrape.log` | `php scrape_batch.php` で手動確認 |
| キャッシュが古い | `psql -c "SELECT * FROM batch_state;"` | `docker compose run --rm batch` |
| コンテナがunhealthy | `docker compose logs --tail=50 api` | ログの原因に応じて対処 |

### 7-6. 事故復旧（サーバBデータ破損時）

```bash
# サーバA で実行
pg_dump -U zinro -d zinro_meta --table=games --table=players \
    --data-only --format=custom --compress=6 -f /tmp/zinro_data.dump
scp /tmp/zinro_data.dump mit@サーバBのIP:/tmp/

# サーバB で実行
psql -U zinro -d zinro_meta -c "TRUNCATE players, games RESTART IDENTITY CASCADE;"
pg_restore -U zinro -d zinro_meta --data-only --disable-triggers /tmp/zinro_data.dump
psql -U zinro -d zinro_meta -c "
    SELECT setval('games_id_seq', (SELECT COALESCE(MAX(id),0) FROM games));
    SELECT setval('players_id_seq', (SELECT COALESCE(MAX(id),0) FROM players));
    ANALYZE games; ANALYZE players;
    TRUNCATE rank_cache_json, personal_stats_cache, batch_state;"

docker compose run --rm batch python batch.py --full
rm /tmp/zinro_data.dump
```

### 7-7. サーバ再起動後

```bash
# Docker API コンテナは restart: unless-stopped で自動起動
docker compose ps

# 起動していなければ
cd /var/www/html/zinro/jinro-api && docker compose up -d

# PostgreSQL 確認
ss -tlnp | grep 5432
# なければ: sudo systemctl start postgresql
```

### 7-8. ログローテーション

```bash
cat > /etc/logrotate.d/zinro << 'EOF'
/var/log/zinro-scrape.log /var/log/jinro-batch.log {
    weekly
    rotate 8
    compress
    missingok
    notifempty
}
EOF
```

### 7-9. ディスク管理（サーバB）

```bash
df -h /
psql -U zinro -d zinro_meta -c "SELECT pg_size_pretty(pg_database_size('zinro_meta'));"
docker image prune -f
```
