---
title: "ラジオオンエア収集 設計書"
---

最終更新: 2026-07-20

## 1. 概要

ラジオ番組のオンエア情報（放送回・出演メンバー・オンエア楽曲）をスクリプトで自動収集し、Neon DB（Postgres）で管理・表示する機能の設計書。

対象番組：

| 番組 | アカウント / 収集元 | スクリプト |
| --- | --- | --- |
| モーニング女学院（ラジオ日本） | X `@morning1422` | `scripts/workflow/collect-onair-morning.ts` |
| ヤングタウン土曜日（MBSラジオ） | X `@yando_staff` | `scripts/workflow/collect-onair-yando.ts` |
| メイボンソワ（STV） | STVサイト HTML | `scripts/workflow/collect-bonsoir-songs.ts` |

---

## 2. DBスキーマ

ラジオの放送データは「放送日（回）」単位で管理されるため、ライブ機能と同様に Neon (Postgres) で管理する。

### 2-1. `radio_shows`（ラジオ番組）

将来的に他の番組にも対応できるよう、番組単位のテーブルを用意する。

```sql
CREATE TABLE "radio_shows" (
  "showId"   VARCHAR(50)   PRIMARY KEY, -- 例: 'morning-jogakuin2026'
  "name"     VARCHAR(255)  NOT NULL,    -- 例: 'モーニング娘。'26 モーニング女学院〜放課後ミーティング〜'
  "station"  VARCHAR(100)               -- 例: 'ラジオ日本'
);
```

### 2-2. `radio_episodes`（放送回）

各放送回の情報を管理。

```sql
CREATE TABLE "radio_episodes" (
  "episodeId" VARCHAR(50)   PRIMARY KEY, -- 例: 'mj-20240504'
  "showId"    VARCHAR(50)   NOT NULL REFERENCES "radio_shows"("showId"),
  "date"      DATE          NOT NULL,
  "title"     VARCHAR(255)               -- 回題（あれば）。例: '737時間目'
);
```

### 2-3. `radio_members`（出演メンバー）

放送回ごとの出演メンバー。日直（メインパーソナリティ）かどうかのフラグを持つ。
PK は `(memberId, episodeId)` 順（メンバー → 出演回の検索を主とする）。

```sql
CREATE TABLE "radio_members" (
  "memberId"  VARCHAR(50)   NOT NULL,    -- Blob members.json の id と対応
  "episodeId" VARCHAR(50)   NOT NULL REFERENCES "radio_episodes"("episodeId"),
  "isDayDuty" BOOLEAN       NOT NULL DEFAULT FALSE, -- 日直フラグ
  PRIMARY KEY ("memberId", "episodeId")
);
CREATE INDEX "radio_members_episodeId_idx" ON "radio_members"("episodeId");
```

### 2-4. `radio_onair_songs`（オンエア楽曲）

各回で放送された楽曲。

```sql
CREATE TABLE "radio_onair_songs" (
  "onairId"    SERIAL        PRIMARY KEY,
  "episodeId"  VARCHAR(50)   NOT NULL REFERENCES "radio_episodes"("episodeId"),
  "trackOrder" INT           NOT NULL,   -- 放送順
  "songTitle"  VARCHAR(255)  NOT NULL,   -- 表示用タイトル
  "artistName" VARCHAR(255),             -- アーティスト名（外部アーティスト楽曲向け）
  "trackId"    VARCHAR(255)              -- Neon の Track.id (recording UUID) への紐付け
);
```

`artistName` はメイボンソワ（§4-3）で利用する拡張フィールド。NULL 許容のため既存データへの影響なし。

---

## 3. UI 設計

### 3-1. ラジオ番組一覧・放送回一覧ページ

- `/radio`: 番組一覧
- `/radio/[show_id]`: 放送回（エピソード）一覧。カレンダーまたは月別アーカイブ形式。
- 各番組はオンエア情報確認用の外部リンク（Xアカウントまたは番組サイト）を一覧・詳細の両画面に表示する。`RadioShow` テーブルを動的にループする実装のため、`lib/radio-helpers.ts` の `RADIO_SHOWS`（showId prefix → 番組名・放送局・確認用リンク）で静的にマッピングする（#1333, #1344）。
- 表示は `components/RadioShowLinkBadge.tsx` によるバッジ形式（ロゴ画像は使わず、サービスのブランドカラーを背景色にしたテキストバッジ。`components/ExternalLinkBadge.tsx` と同じ考え方）。Xアカウントは`@アカウント名`をバッジ文言、黒背景（Xの現行ブランドカラー）。番組サイトは「公式サイト」をバッジ文言、グレー背景（#1344レビューより）。

| 番組 | リンク種別 | バッジ文言 | URL |
| --- | --- | --- | --- |
| モーニング女学院 | Xアカウント | `@morning1422` | https://x.com/morning1422 |
| ヤングタウン土曜日 | Xアカウント | `@yando_staff` | https://x.com/yando_staff |
| 井上春華のはるさんち | 番組サイト | `公式サイト` | https://hicbc.com/radio/harusanchi/ |
| メイボンソワ | 番組サイト | `公式サイト` | https://www.stv.jp/radio/bonsoir/senkyoku/index.html |

### 3-2. 放送回詳細ページ

- `/radio/episodes/[episode_id]`
- 表示内容: 放送日、出演メンバー（日直にはバッジ等を表示）、オンエアリスト（楽曲一覧）。
- 楽曲は `track_id` がある場合、曲詳細ページ (`/songs/[id]`) へリンク。

### 3-3. 曲詳細ページへの追加

- `/songs/[id]` の「オンエア情報」セクション。
- この楽曲が過去にどの放送回で流れたかをリスト表示する。

### 3-4. メンバー詳細ページへの追加

- `/members/[id]` の「ラジオ出演」セクション。
- 過去の出演回をリスト表示。日直回は強調する。

### 3-5. タイムラインのアクセントカラー

タイムライン（年ビュー・週ビュー・日ビュー）のイベントカードは、`type === "media"` のとき番組名（タイトル文字列の部分一致）に応じてアクセントカラー（左ボーダー・テキストアート背景色）を表示する。実装は `components/EventCard.tsx` の `RADIO_SHOW_COLORS`。

| 番組 | 色 | カラーコード |
| --- | --- | --- |
| モーニング女学院 | オレンジ | `#fb923c` |
| ヤングタウン土曜日 | スカイブルー | `#38bdf8` |
| メイボンソワ | バイオレット | `#a78bfa` |
| 井上春華のはるさんち | エメラルドグリーン | `#34d399` |

未登録の番組はアクセントカラーなし（無地表示）となる。色は番組を視覚的に区別するための任意色であり、パーソナリティのメンバーカラーとは連動しない。新規番組追加時は、既存4色と被らない色を `RADIO_SHOW_COLORS` に追加すること。

---

## 4. 番組別 収集設計

### 4-0. 番組共通の楽曲登録・紐付け方針

番組ごとの収集元（X投稿・HTMLスクレイピング・手動データ等）によらず、`RadioOnairSong` への楽曲登録・trackId紐付けは以下の方針を共通とする。

**楽曲未紐付け時の警告ログ**

MM関連曲（アーティスト名なし）としてtrackId解決を試みたが `Track` テーブルに一致する曲が見つからなかった場合は、`楽曲未登録の可能性` として `logger.warn` で警告を出す（`register-manual-radio-episodes.ts`, `collect-onair-morning.ts` で実施）。サイレントに `trackId: null` のまま処理を続けると、紐付け漏れに気づけないため。

**`trackOrder` の重複防止**

同一放送回内で `trackOrder` が重複したまま `RadioOnairSong.createMany` に渡すと、`@@unique([episodeId, trackOrder])` 制約違反でスクリプトごと停止する。収集元パース時点（`collect-onair-morning.ts` の `groupTweetsByEpisode` 等）または入力バリデーション時点（`register-manual-radio-episodes.ts` の手動JSONスキーマ）で重複を検出・排除すること。

**trackId未解決曲の再紐付け（再実行時）**

放送時点でリリース未登録だった曲（新曲の場合など）は `trackId: null` で登録されるが、後日 `Track` テーブルに追加された際に再実行で自動的に紐付けられるよう、`trackId: null` かつ `artistName: null` の `RadioOnairSong` を対象とした再紐付けパスを、収集・登録処理の最後に設ける（`collect-onair-morning.ts`, `collect-bonsoir-songs.ts`, `register-manual-radio-episodes.ts` で共通実施）。

**曲名抽出誤りの是正（一回限りのデータパッチ、#1292）**

PR #1202 より前のモーニング女学院曲目ツイート抽出処理では、リリース紹介文などの説明文が曲名の先頭に混入するケースがあり、`artistName: null` かつ `trackId: null` のまま「説明文：正しい曲名」という形式のレコードが生成されることがあった（抽出処理自体は PR #1202 で改善済みで新規データでは発生しない）。PR #1202 より前に登録されたレコードは、`scripts/patch/patch-radio-onair-song-titles.ts` で末尾の「：」以降を正しい曲名として再抽出し、track-matchで再紐付けする（一回限りの是正パッチ）。

### 4-1. モーニング女学院（@morning1422）

> **定期実行は停止済み（#1333）**: X APIのタイムライン取得上限（直近3,200件付近、#1331）に到達し、これ以上の遡及取得ができなくなったため、`collect-onair-morning.yml` の `schedule` トリガーを削除した。`workflow_dispatch` による手動実行はいつでも可能。以下は当時の自動収集設計であり、手動実行時の参考として残す。新規放送分は `register-manual-radio-episodes.ts`（§4-4）による手動登録に統一する。

#### データソース

| 項目 | 内容 |
| --- | --- |
| 収集元 | X アカウント `@morning1422` |
| 収集手法 | twitterapi.io `GET /twitter/user/last_tweets`（§5参照） |
| 使用 Secret | `TWITTERAPI_IO_KEY`、`DATABASE_URL`、`BLOB_READ_WRITE_TOKEN` |
| 放送スケジュール | 毎週土曜深夜0時（JST） |
| 収集タイミング | 毎週日曜午前 JST 09:00（UTC 00:00）に GitHub Actions で実行 |

#### ツイートフォーマットと収集フィールド

**曲目ツイート（放送中）**

```
🔔ラジオ日本「モーニング娘。'26のモーニング女学院～放課後ミーティング～」今週の2曲目 🔔涙にはしたくない／モーニング娘。☆ #morning1422 #小田さくら #岡村ほまれ #morningmusume26 #モーニング娘26
```

曲目ツイートは `🔔` の直後に全角コロン `：` が付く形式も存在する：

```
🔔ラジオ日本「…スペシャル」今週の4曲目 🔔：ピョコピョコ ウルトラ／モーニング娘。☆ #morning1422
```

| 収集フィールド | 抽出方法 |
| --- | --- |
| トラック番号 | `今週の(\d+)曲目` |
| 曲名 | `曲目\s*🔔[：:]?\s*(.+?)[／\/]`（コロン有無両対応） |
| 出演メンバー | ハッシュタグ `#メンバー名` から `members.json` と照合 |

**メンバー告知ツイート（放送前）**

```
🔔今夜のメンバー告知🔔
#ラジオ日本「モーニング娘。'26のモーニング女学院」土曜深夜0時‼️
12月21日（土）は #牧野真莉愛 さん #北川莉央 さん #山﨑愛生 さんの3人出席
（日直は牧野さん）
#morning1422 #morningmusume26 #モーニング娘26
```

| 収集フィールド | 抽出方法 |
| --- | --- |
| 放送日 | `(\d+)月(\d+)日（土）` → Date 変換（年はツイート投稿日から推定） |
| 出演メンバー | ハッシュタグ `#メンバー名` から `members.json` と照合 |
| 日直 | `日直は(.+?)さん` → `isDayDuty = true` |

- 放送日の年推定ロジック: 月差が6ヶ月超の場合のみ年を調整（年越し告知への対応）

**エピソードタイトルツイート（放送後）**

```
🔔ラジオ日本「モーニング娘。'26のモーニング女学院～放課後ミーティング～」🔔【737時間目】#morning1422
```

| 収集フィールド | 抽出方法 |
| --- | --- |
| エピソードタイトル | `【(\d+時間目)】` 形式のテキストを抽出 |
| 放送日 | ツイート投稿日時から直近の土曜日を算出（`nearestSaturday`） |

#### スキーママッピング

| 収集データ | マッピング先 | 備考 |
| --- | --- | --- |
| 放送日 | `RadioEpisode.date` | |
| エピソードタイトル | `RadioEpisode.title` | 「737時間目」等。取得できない場合は `null` |
| 出演者 | `RadioMember.memberId` | ハッシュタグ → `members.json` 照合 |
| 日直 | `RadioMember.isDayDuty = true` | メンバー告知ツイートから取得 |
| 曲名 | `RadioOnairSong.songTitle` | |
| トラック番号 | `RadioOnairSong.trackOrder` | 「今週のN曲目」のN |
| トラックID | `RadioOnairSong.trackId` | 収集後に `Neon releases` とシングル優先照合で自動付与（#1014） |

#### 設計方針の決定事項

**showId 採番ルール**

年ごとに番組IDを変える（例: `morning-jogakuin2026`）。

**episodeId の採番ルール**

```
mj-YYYYMMDD
例: mj-20260510（2026年5月10日放送分）
```

**ページネーション方針**

実行ごとに2フェーズで取得する。総ページ予算は `MORNING_ONAIR_MAX_PAGES`（デフォルト: 20）で、両フェーズで共有する。

- **当週放送回取得（必須）**: DB 登録済みの放送回（`episodeId`）に突き当たるまで取得する早期終了モード（`hasRegisteredEpisode`）。何週分か取り逃した場合も DB 既存回に達するまで自動的に取得するため、取り逃し週数に依存しない。常に実行し、最大 `MORNING_ONAIR_MAX_PAGES` ページを上限とする。
- **遡及取得（オプション）**: Vercel Blob（`morning-onair-cursor.json`）にカーソルが保存されており、かつ当週放送回取得後に残りページ数がある場合のみ実行。前回中断位置から残りページ数（`MORNING_ONAIR_MAX_PAGES - phase1Pages`）を上限として取得を再開し、続きのカーソルを Blob に保存する（次回継続）。`has_next_page=false` になった場合は Blob に `{ done: true }` を保存し次回から通常モードに戻るが、これは**投稿履歴の終端ではなく X APIのタイムライン取得上限（直近3,200件付近）への到達**であり、それ以前の投稿は本APIでは取得できない（#1331）。当週放送回取得でページ予算を使い切った場合は遡及をスキップし、次回実行時に再開する。

ページ間インターバルは環境変数 `TWITTERAPI_IO_PAGE_INTERVAL_MS` で設定可能（デフォルト: 5000ms）。

**重複処理方針（冪等性）**

- `episodeId` が DB に存在する場合: 不足データを補完してスキップ
  - 未登録の楽曲を `radio_onair_songs` に追加
  - `title` が未設定の場合は `radio_episodes` を更新
- 楽曲の重複は `(episodeId, trackOrder)` の UNIQUE 制約で防止

#### スクリプト概要

| 項目 | 内容 |
| --- | --- |
| スクリプト | `scripts/workflow/collect-onair-morning.ts` |
| 実行タイミング | 毎週日曜午前 JST 09:00（UTC 00:00）に GitHub Actions で実行 |
| 使用 Secret | `TWITTERAPI_IO_KEY`、`DATABASE_URL`、`BLOB_READ_WRITE_TOKEN` |
| 環境変数 | `MORNING_ONAIR_MAX_PAGES`（遡及取得の1回あたり上限ページ数、デフォルト: 20） |

**処理フロー**

```
1. DB 登録済み episodeId 一覧を事前ロード（当週放送回取得の停止条件に使用）
2. twitterapi.io で @morning1422 の当週放送回ツイートを取得（必須）
   → hasRegisteredEpisode（DB 登録済み放送回に突き当たった時点）で停止
   → 最大 MORNING_ONAIR_MAX_PAGES ページを上限とする
   → HTTPエラー・レート制限は警告ログを出してその時点までの取得分を返す（フェールセーフ）
3. Vercel Blob から遡及カーソルを読み込む
   カーソルあり、かつ残りページ数あり（遡及モード）:
   → twitterapi.io でカーソル位置から残りページ数（MORNING_ONAIR_MAX_PAGES - phase1Pages）を取得（オプション）
   → 同様にフェールセーフ。例外発生時はカーソル更新をスキップ（次回同位置から再開）
   → 次のカーソルがあれば Blob に保存（次回継続）
   → 次のカーソルがなければ Blob に `{ done: true }` を保存（取得上限到達・通常モードに戻る。投稿履歴の終端ではない点に注意）
   カーソルあり、かつ残りページ数なし:
   → 遡及をスキップ（次回実行時に再開）
   カーソルなし（通常モード）:
   → 当週放送回取得のみで完了
4. 取得ツイートからエピソードを解析して DB に登録
   → メンバー告知ツイート（放送日・出演メンバー・日直）
   → 曲目ツイート（トラック番号・曲名）
   → エピソードタイトルツイート（「737時間目」等）
   episodeId が DB に存在しない場合: 新規 INSERT
   → radio_episodes に放送回（タイトル含む）を挿入
   → radio_members に出演メンバーを挿入（日直フラグあり）
   → radio_onair_songs にオンエア楽曲を挿入
   episodeId が DB に存在する場合: 不足分を補完
   → 未登録の楽曲を radio_onair_songs に追加
   → title が未設定の場合は radio_episodes を更新
5. 実行サマリーをログ出力（LOG_LEVEL=INFO で進捗、LOG_LEVEL=DEBUG で詳細）
```

---

### 4-2. ヤングタウン土曜日（@yando_staff）

> **定期実行は停止済み（#1280, #1333）**: 手動登録の方が容易なため `collect-onair-yando.yml` の `schedule` トリガーを削除した。`workflow_dispatch` による手動実行はいつでも可能。以下は当時の自動収集設計であり、手動実行時の参考として残す。新規放送分は `register-manual-radio-episodes.ts`（§4-4）による手動登録に統一する。

#### データソース

| 項目 | 内容 |
| --- | --- |
| 収集元 | X アカウント `@yando_staff` |
| 収集手法 | twitterapi.io `GET /twitter/user/last_tweets`（§5参照） |
| 使用 Secret | `TWITTERAPI_IO_KEY`、`DATABASE_URL` |
| 放送スケジュール | 毎週土曜（MBSラジオ） |
| 収集タイミング | 毎週日曜午前 JST 09:00（UTC 00:00）に GitHub Actions で実行 |

#### ツイートフォーマットと収集フィールド

`#yando` または `#ヤン土` ハッシュタグを含むツイートを収集対象とする。

| 収集フィールド | 抽出方法 |
| --- | --- |
| 出演メンバー | ハッシュタグ `#メンバー名` から `members.json` と照合 |
| 放送日 | ツイート投稿日時から直近の土曜日を算出（`nearestSaturday`） |

モーニング女学院と異なり、楽曲情報・エピソードタイトル・日直の収集は対象外。

#### スキーママッピング

| 収集データ | マッピング先 | 備考 |
| --- | --- | --- |
| 放送日 | `RadioEpisode.date` | |
| 出演者 | `RadioMember.memberId` | `isDayDuty = false` |

#### 設計方針の決定事項

**showId**

```
young-town-saturday
```

**episodeId の採番ルール**

```
yt-YYYYMMDD
例: yt-20260510（2026年5月10日放送分）
```

**ページネーション方針**

最新放送回のメンバー情報（出演者ハッシュタグ）が取得できた時点でページ取得を停止する（`isLatestYandoEpisodeComplete`）。
2018年分まで遡及取得済みのため、最新回の情報が揃い次第停止することで不要なページ取得を抑制する。

**重複処理方針（冪等性）**

- `episodeId` が DB に存在する場合はスキップする

#### スクリプト概要

| 項目 | 内容 |
| --- | --- |
| スクリプト | `scripts/workflow/collect-onair-yando.ts` |
| 実行タイミング | 毎週日曜午前 JST 09:00（UTC 00:00）に GitHub Actions で実行 |
| 使用 Secret | `TWITTERAPI_IO_KEY`、`DATABASE_URL` |

**処理フロー**

```
1. twitterapi.io で @yando_staff の直近ツイートを取得（最新放送回のメンバーが揃った時点で停止: `isLatestYandoEpisodeComplete`）
2. #yando / #ヤン土 ハッシュタグを含むツイートを抽出
   → ハッシュタグからメンバー情報を取得
3. episodeId が DB に存在しない場合: 新規 INSERT
   → radio_episodes に放送回を挿入
   → radio_members に出演メンバーを挿入（isDayDuty = false）
   episodeId が DB に存在する場合: スキップ
4. 実行サマリーをログ出力
```

---

### 4-3. メイボンソワ（STVサイト）

> **定期実行は停止済み（#1333）**: モーニング女学院・ヤングタウン土曜日と同様に手動登録方式へ統一するため `collect-bonsoir-songs.yml` の `schedule` トリガーを削除した。`workflow_dispatch` による手動実行はいつでも可能。以下は当時の自動収集設計であり、手動実行時の参考として残す。新規放送分は `register-manual-radio-episodes.ts`（§4-4）による手動登録に統一する。

#### データソース

| 項目 | 内容 |
| --- | --- |
| 収集元 | `https://www.stv.jp/radio/bonsoir/senkyoku/index.html` |
| 過去回 | `https://www.stv.jp/radio/bonsoir/senkyoku/{ID}.html`（IDはランダムな英数字） |
| 収集手法 | 静的HTMLスクレイピング（fetch + 正規表現） |
| JavaScript レンダリング | 不要 |
| 放送スケジュール | 毎週土曜放送 |
| 収集タイミング | 毎週日曜午前 JST 09:00（UTC 00:00）に GitHub Actions で実行 |
| APIキー | 不要（コスト無料） |

**収集対象: パーソナリティが山﨑愛生のコーナーのみ。**

#### HTMLページ構造と収集フィールド

ページ内コンテンツ例：

```
放送日: 2026年5月9日(土)
コーナー名: 稲場愛香のまなかん♡ボンソワ
パーソナリティ: 稲場愛香

M1「熱狂バイレ」（稲場愛香）
M2「Hanabira」（3House）
M3「雫に恋して」（indigo la End）
M4「Die With a Smile」（レディーガガ&ブルーノマーズ）
```

楽曲データは `<p>` タグにプレーンテキストで記載（構造化マークアップなし）。通常4曲（M1〜M4）。

| フィールド | 正規表現 | 例 |
| --- | --- | --- |
| 放送日 | `/(\d{4})年(\d{1,2})月(\d{1,2})日/` | `2026年5月9日` |
| パーソナリティ | パーソナリティフィールドまたはコーナー名から抽出 | `山﨑愛生` |
| トラック番号 | `/M(\d+)/` | `M1` → `1` |
| 曲名 | `/M\d+「(.+?)」/` | `熱狂バイレ` |
| アーティスト名 | `/M\d+「.+?」（(.+?)）/` | `3House` |

#### スキーママッピング

| 収集データ | マッピング先 | 備考 |
| --- | --- | --- |
| 放送日 | `RadioEpisode.date` | |
| パーソナリティ（山﨑愛生） | `RadioMember.memberId` | `isDayDuty = false`（日直概念なし） |
| 曲名 | `RadioOnairSong.songTitle` | |
| トラック番号 | `RadioOnairSong.trackOrder` | M1 → 1 |
| アーティスト名 | `RadioOnairSong.artistName` | §2-4 の拡張フィールド |

#### 設計方針の決定事項

**showId**

```
mei-bonsoir
```

**episodeId の採番ルール**

```
mb-YYYYMMDD
例: mb-20260510（2026年5月10日放送分）
```

**収集対象フィルタリング**

- パーソナリティが「山﨑愛生」のページのみ収集する
- 他のパーソナリティ（例: 稲場愛香、北川莉央 等）のコーナーはスキップする

**RadioMember の扱い**

パーソナリティは山﨑愛生のみ固定。STVサイトにゲスト出演者情報はないため、パーソナリティのみを格納する（`isDayDuty = false`）。

**重複処理方針（冪等性）**

- `episodeId` が DB に存在する場合はスキップする
- 楽曲の重複は `(episodeId, trackOrder)` の UNIQUE 制約（既存スキーマ）で防止する

**過去データ遡及取得の方針**

- `index.html` の最新回から「過去の放送回へのリンク」を再帰的にたどって遡及取得する
- DB に既存の `episodeId` を発見した時点で遡及を打ち切る

#### スクリプト概要

| 項目 | 内容 |
| --- | --- |
| スクリプト | `scripts/workflow/collect-bonsoir-songs.ts` |
| 実行タイミング | 毎週日曜午前 JST 09:00（UTC 00:00）に GitHub Actions で実行 |
| 使用 Secret | `DATABASE_URL` |

**処理フロー**

```
1. https://www.stv.jp/radio/bonsoir/senkyoku/index.html を fetch
2. パーソナリティ = 山﨑愛生か確認（異なる場合はスキップして過去回リンクをたどる）
3. 放送日・楽曲データを正規表現でパース
4. episodeId が DB に存在しない場合のみ INSERT
   → radio_episodes に放送回を挿入
   → radio_members に山﨑愛生を挿入（isDayDuty = false）
   → radio_onair_songs にオンエア楽曲を挿入（artistName を含む）
5. 過去回リンクを再帰的にたどる（DB 既存エピソードで打ち切り）
6. 実行サマリーをログ出力
```

**環境変数**

| 変数名 | 必須 | 説明 |
| --- | --- | --- |
| `DATABASE_URL` | ○ | Neon DB 接続文字列（既存） |


---

### 4-4. 手動登録（`register-manual-radio-episodes.ts`）

- 元々は井上春華のはるさんち専用（自動収集は未実装のため手動データを読み込むスクリプトで登録、#1274）だったが、#1333でモーニング女学院・ヤングタウン土曜日・メイボンソワの自動収集を停止したことに伴い、4番組共通の手動登録スクリプトへ汎用化した
- `getShowMeta(showId)` は `lib/radio-helpers.ts` の `getRadioShowMeta` の re-export で、showId の prefix（`morning-jogakuin` / `young-town-saturday` / `mei-bonsoir` / `harusan-chi`）で番組名・放送局を判定する。対応するprefixがない showId はエラーを投げる。§3-1のオンエア情報確認用リンクと同じ `RADIO_SHOWS` レジストリを参照するため、新規番組を追加する場合は `lib/radio-helpers.ts` の1箇所を更新すればよい（#1344）

#### データソース

| 項目 | 内容 |
| --- | --- |
| 収集元 | `data/inputs/manual-radio-episodes.json`（コマンドライン引数未指定時のデフォルトパス）。手動作成データで、恒久データではなくスクリプト実行に合わせて用意する一時データ（リポジトリにコミットしない） |
| 収集手法 | 手動JSONファイルの読み込み（自動収集なし） |
| 実行方式 | スクリプト実行のみ（GitHub Actions ワークフローには組み込まない） |

**入力ファイル形式（例: `data/inputs/manual-radio-episodes.json`）**

```json
{
  "showId": "harusan-chi2026",
  "episodes": [
    {
      "episodeId": "hs-20260629",
      "title": "【743時間目】",
      "members": [
        { "memberId": "inoue-haruka", "isDayDuty": true }
      ],
      "songs": [
        { "trackOrder": 1, "title": "すき焼き", "artist": "" },
        { "trackOrder": 2, "title": "ロマンスの神様", "artist": "広瀬香美" }
      ]
    }
  ]
}
```

`members[].isDayDuty` は省略可能（省略時は `false`）。1エピソードにつき `isDayDuty: true` を指定できるのは0〜1名まで（2名以上指定するとZodバリデーションでエラーになりファイル全体の読み込みが失敗する）。

`episode.title` は省略可能。モーニング女学院のように放送回ごとにタイトル（回数表記等）が存在する番組で指定する。省略時は `RadioEpisode.title` は `null` のまま。

**複数番組のまとめ登録（#1394）**

入力ファイルは番組ごとに1つ（`showId` は1ファイルにつき1件）。複数番組をまとめて登録したい場合は、番組ごとに別ファイルを用意し、コマンドライン引数でファイルパスを指定して番組数分実行する。

```bash
bun run scripts/console/register-manual-radio-episodes.ts data/inputs/harusan-radio-episodes.json
bun run scripts/console/register-manual-radio-episodes.ts data/inputs/young-town-radio-episodes.json
```

引数省略時は従来通り `data/inputs/manual-radio-episodes.json` を読み込む。

#### スキーママッピング

| 収集データ | マッピング先 | 備考 |
| --- | --- | --- |
| showId | `RadioShow.showId` | |
| episodeId | `RadioEpisode.episodeId` | |
| title | `RadioEpisode.title` | 省略可。既存放送回に後から補完する場合は、既存の `title` が `null` の場合のみ反映する（上書きしない） |
| members[].memberId | `RadioMember.memberId` | 実在チェックは `MEMBERS_BLOB_URL` から取得したメンバー一覧と突き合わせる。一致しない場合は警告ログを出すのみで登録は継続する（メンバー一覧取得に失敗した場合もチェックをスキップして登録は継続する） |
| members[].isDayDuty | `RadioMember.isDayDuty` | 省略時は `false`。1エピソードにつき `true` は0〜1名まで（#1394）。既存放送回に既に日直が登録されている場合、補完メンバーの `isDayDuty: true` は無視され `false` として登録される（警告ログを出す） |
| songs[].title | `RadioOnairSong.songTitle` | |
| songs[].trackOrder | `RadioOnairSong.trackOrder` | |
| songs[].artist | `RadioOnairSong.artistName` / `RadioOnairSong.trackId` | `artist` が空文字の場合はモーニング娘。関連曲としてタイトルからTrackIdを解決（`artistName` はnull）。`artist` 指定がある場合はそのまま `artistName` に格納（`trackId` はnull） |

#### 設計方針の決定事項

**showId 採番ルール**

| 番組 | showId | 年サフィックス |
| --- | --- | --- |
| モーニング女学院 | `morning-jogakuin{year}` | あり（§4-1のshowId採番ルールを踏襲） |
| ヤングタウン土曜日 | `young-town-saturday` | なし（固定） |
| メイボンソワ | `mei-bonsoir` | なし（固定） |
| 井上春華のはるさんち | `harusan-chi{year}` | あり |

`getShowMeta(showId)` はこの表の prefix で番組名・放送局を判定するため、新規番組を追加する場合は `lib/radio-helpers.ts` の `RADIO_SHOWS` にエントリを追加すること（§3-1のオンエア情報確認用リンクも同じレジストリから導出される）。

**episodeId の採番ルール**

```
hs-YYYYMMDD
例: hs-20260629（2026年6月29日放送分）
```

**TrackId紐付け方式**

`normalizeTitleForMatch` による正規化タイトルの完全一致のみ（`collect-bonsoir-songs.ts` と同方式）。あいまい一致（`findBestMatch`）は使わない。§4-0 の方針通り、未紐付け時は警告ログを出す。

**登録・補完方針（冪等性）**

- 放送回（`RadioEpisode`）が未登録の場合は新規作成する
- 既存の放送回に対しては項目単位で不足分のみ補完し、既に揃っている項目は上書きしない
  - タイトル（`RadioEpisode.title`）が未設定（`null`）であれば補完する
  - 出演メンバー（`RadioMember`）が不足していれば追加する（既に日直が登録済みの放送回に対し、`isDayDuty: true` のメンバーを追加しようとした場合は `false` に矯正して警告ログを出す。§4-4 参照）
  - オンエア楽曲（`RadioOnairSong`）が `trackOrder` 単位で不足していれば追加する
- 手動JSONの `songs` 内で `trackOrder` が重複している場合は、Zodスキーマのバリデーション時点でエラーとし処理をスキップする（§4-0 参照）
- 手動JSONの `members[].memberId` が `MEMBERS_BLOB_URL` のメンバー一覧に存在しない場合は警告ログを出す（タイポ検知。登録自体はブロックしない）
- trackId未解決曲の再紐付けも §4-0 の方針に従い、今回処理した放送回に絞って毎回実行する

#### スクリプト概要

| 項目 | 内容 |
| --- | --- |
| スクリプト | `scripts/console/register-manual-radio-episodes.ts` |
| 実行タイミング | 手動実行のみ（ワークフロー組み込みなし） |
| 使用 Secret | `DATABASE_URL`、`MEMBERS_BLOB_URL`（memberId 存在チェック用。未設定時はチェックのみスキップし処理は継続） |

**処理フロー**

```
1. 入力ファイル（コマンドライン引数で指定、未指定時は data/inputs/manual-radio-episodes.json）を読み込む
   （Zodスキーマでバリデーション。episode内のtrackOrder重複・isDayDuty重複もここで検出）
   → ファイル未配置・バリデーション失敗時は処理をスキップして終了（一時データのため正常系）
2. MEMBERS_BLOB_URL からメンバー一覧を取得し、members[].memberId の実在チェックを行う（不一致は警告ログのみ。取得失敗時はチェックをスキップ）
3. radio_shows に showId を upsert
4. 各 episode ごとに既存 radio_episodes（onairSongs・members含む）を取得
   → 未登録なら放送回・出演メンバー・オンエア楽曲を新規作成（title を指定していれば設定）
   → 登録済みなら不足しているタイトル・メンバー・楽曲のみ項目単位で補完
5. 楽曲の trackId 解決: artist が空文字の曲のみ、正規化タイトル完全一致で trackId を解決。未紐付けの場合は警告ログを出す
6. 実行サマリーをログ出力
7. 今回処理した放送回に絞って、trackId未解決の楽曲を再紐付け（§4-0 参照）
```

---

## 5. twitterapi.io 仕様（X投稿収集共通）

モーニング女学院・ヤングタウン土曜日の収集に使用する `twitterapi.io` の API 仕様。

### 5-1. 認証方法

| 項目 | 内容 |
|------|------|
| 認証方式 | `x-api-key` ヘッダー単一認証 |
| OAuth | 不要 |

### 5-2. エンドポイント仕様: GET /twitter/user/last_tweets

**パラメータ**

| パラメータ | 必須 | 説明 |
|-----------|------|------|
| `userName` | 必須（`userId` との択一） | Twitterハンドル（`@` 付き or 無しどちらも可） |
| `userId` | 必須（`userName` との択一） | Twitter ユーザーID |
| `cursor` | 任意 | ページネーション用カーソル。初回は省略 |
| `includeReplies` | 任意 | リプライを含めるか（デフォルト: `false`） |

動作確認済みの呼び出し例：

```bash
curl --request GET \
  --url 'https://api.twitterapi.io/twitter/user/last_tweets?includeReplies=false&userName=%40morning1422' \
  --header 'x-api-key: <TWITTERAPI_IO_KEY>'
```

**レスポンス形式**

```json
{
  "data": {
    "tweets": [...]
  },
  "has_next_page": true,
  "next_cursor": "some_cursor_string"
}
```

| フィールド | 型 | 説明 |
|-----------|-----|------|
| `data.tweets` | Tweet[] | 取得ツイート一覧（1ページ最大20件） |
| `has_next_page` | boolean | 次ページが存在するか |
| `next_cursor` | string | 次ページ取得用カーソル |

**Tweet オブジェクトのフィールド**

| フィールド | 型 | 説明 |
|-----------|-----|------|
| `id` | string | ツイートID |
| `text` | string | ツイート本文 |
| `createdAt` | string | 投稿日時（ISO 8601） |
| `url` | string | ツイートURL |
| `author` | UserInfo | 投稿者情報 |
| `entities` | object | ハッシュタグ・URL・メンション情報 |

### 5-3. ページネーション

- 1ページあたり最大 **20件**
- `has_next_page: true` の場合、次ページが存在する
- `next_cursor` の値を次のリクエストの `cursor` パラメータに渡す
- ページ間インターバルは `TWITTERAPI_IO_PAGE_INTERVAL_MS` 環境変数で設定（デフォルト: 5000ms）

実装は `scripts/lib/twitterapi-client.ts` の `fetchUserTweets` を共通利用する。

### 5-4. 料金試算

| 項目 | 単価 |
|------|------|
| ツイート取得 | $0.15 / 1,000件 |
| 無料試用クレジット | サインアップ時に付与（$0.10 相当） |

| シナリオ | ページ数 | 取得件数 | 費用 |
|---------|---------|---------|------|
| 週次実行 2アカウント（morning + yando） | 2〜4ページ | 40〜80件 | $0.006〜$0.012 |
| 月換算（2アカウント × 4週） | 16ページ | 320件 | ~$0.048 / 月 |

月間コストは **$0.05 以下** に収まる見込み。

---

## 6. 改訂履歴

| 版 | 更新日 | 変更内容 |
| --- | --- | --- |
| 2.15 | 2026-07-20 | §4-4: 手動放送回登録スクリプトに `episode.title`（エピソードタイトル、モーニング女学院向け）を追加。あわせて `members[].memberId` の実在チェック（`MEMBERS_BLOB_URL` 突合・警告ログ）と、既存放送回に既に日直がいる場合の補完メンバーの日直指定を無視するガードを追加（PR #1395 レビュー対応） |
| 2.14 | 2026-07-20 | §4-4: 手動放送回登録スクリプトに日直指定（`members[].isDayDuty`、1エピソードにつき0〜1名）と、コマンドライン引数による入力ファイルパス指定（複数番組のまとめ登録）を追加。あわせて入力ファイルの参照パスを実際の配置 `data/inputs/manual-radio-episodes.json` に是正（#1394） |
| 2.13 | 2026-07-20 | §4-1: 定期実行停止の理由を「自動遡及取得の価値がなくなったため」から「これ以上の遡及取得ができなくなったため」に是正（X APIのタイムライン取得上限は原理的に超えられない制約であり、価値判断の結果停止したものではない、#1376 対応中に発見） |
| 2.12 | 2026-07-19 | §1, §4-1, §4-2, §4-3: 収集スクリプトの置き場所を `scripts/collect-onair-*.ts` から実際の配置 `scripts/workflow/collect-onair-*.ts` 等に是正（#1375） |
| 2.11 | 2026-07-15 | §3-1: オンエア情報確認用リンクの表示をテキストリンクから `components/RadioShowLinkBadge.tsx` によるバッジ形式に変更。`RadioShowLink` 型に `badgeLabel`・`badgeColor` を追加し、Xアカウントは`@アカウント名`＋黒背景、番組サイトは「公式サイト」＋グレー背景で表示（#1344 レビュー対応） |
| 2.10 | 2026-07-15 | §3-1, §4-4: `lib/radio-helpers.ts` の `RADIO_SHOW_LINKS` と `scripts/console/register-manual-radio-episodes.ts` の `SHOW_META_BY_PREFIX` に分かれていたshowId prefix定義を `lib/radio-helpers.ts` の `RADIO_SHOWS` に一本化。`getShowMeta` は `getRadioShowMeta` の re-export に変更（#1344 レビュー対応） |
| 2.9 | 2026-07-15 | §3-1: 各番組のオンエア情報確認用外部リンク（Xアカウント／番組サイト）追加を追記。§4-1〜4-3: モーニング女学院・ヤングタウン土曜日・メイボンソワの自動収集ワークフロー停止を追記。§4-4: `register-manual-radio-episodes.ts` の4番組共通対応（`getShowMeta` のprefixマッピング化）を追記し節タイトルを「手動登録」に変更（#1333） |
| 2.8 | 2026-07-14 | §4-0: PR #1202 より前に登録された曲名抽出誤りレコードを是正するスクリプト（`patch-radio-onair-song-titles.ts`）を追加（#1292） |
| 2.7 | 2026-07-13 | §3-5（新設）: タイムラインのラジオ番組アクセントカラー一覧を追記。井上春華のはるさんちに未登録だったアクセントカラー（`#34d399`）を追加した修正に合わせて記載（#1325） |
| 2.6 | 2026-07-12 | §4-1 モーニング女学院: 遡及取得の `done`（`has_next_page=false`）は投稿履歴の終端ではなくX APIの取得上限（直近3,200件付近）への到達である旨を明記（#1331） |
| 2.5 | 2026-07-04 | §4-0（新設）: 番組共通の楽曲登録・紐付け方針（警告ログ・trackOrder重複防止・trackId再紐付け）を追記。§4-4: PRレビュー指摘を踏まえ、警告ログ・trackOrder重複バリデーション・再紐付けパスの追加を反映（PR #1276 レビューより） |
| 2.4 | 2026-07-04 | §4-4 井上春華のはるさんち: 手動データ登録スクリプトのデータソース・スキーママッピング・登録方針・処理フローを追記（#1274） |
| 2.3 | 2026-06-25 | §4-4 モーニング娘。'26 井上春華のはるさんちの設計方針を追記（#1217） |
| 2.2 | 2026-06-07 | §4-1 モーニング女学院: ページネーション方針を2フェーズ制（当週放送回必須＋遡及オプション）に変更。停止条件を `hasRegisteredEpisode`（DB登録済み放送回に突き当たるまで取得）に変更。ページ予算共有・フェールセーフ動作を追記（#1083） |
| 2.1 | 2026-06-07 | §4-1 モーニング女学院: ページネーション方針を「停止条件なし・ページ終端まで取得」に変更。§4-2 ヤングタウン土曜日: `isLatestYandoEpisodeComplete` による停止条件を設計方針・処理フローに追記（#1080, #1081） |
| 2.0 | 2026-05-27 | `bonsoir-data-design.md`・`morning1422-data-design.md`・`twitterapi-io-spec.md` を本ドキュメントに集約（#1012）。§2「データソース」の旧 RapidAPI 記述を twitterapi.io に統一 |
| 1.1 | 2026-05-27 | モーニング女学院 §4-1 にエピソードタイトルツイート・スマートページネーション・インターバル環境変数を追記（#1009, #1013） |
| 1.0 | 2026-05-22 | DBスキーマ・UI案・データ運用方針の初版（#920） |
