---
title: "YouTube投稿一覧機能 データ設計書"
---

最終更新: 2026-07-30

## 1. 概要

モーニング娘。公式 YouTube チャンネルおよび OG メンバー個人チャンネルの動画投稿を YouTube Data API v3 経由で取得し、Neon（PostgreSQL）に保存する。

---

## 2. Neon テーブル一覧

| テーブル名 | 概要 | 更新タイミング |
| --- | --- | --- |
| `youtube_posts` | 収集済み動画メタデータ | `collect-youtube-official`（公式・#1103）/ `collect-youtube-og`（OG・#1104）実行時 |
| `youtube_sync_status` | TOP新着投稿セクションの新着ウィンドウ判定に使う同期ステータス（1行のみ。詳細は §6.2） | `collect-youtube-official` / `collect-youtube-og` 実行時（同じ1行の中で、それぞれ自分のチャンネル用カラムのみを更新。#1450 のレビューで判明した回帰対応、後述） |

---

## 3. 型定義

型定義ファイル: `types/youtube.ts`

### `YoutubePost`（投稿メタデータ）

```ts
type YoutubePost = {
  videoId: string;              // YouTube 動画 ID（11 文字）
  channelType: 'official' | 'og'; // チャンネル種別
  memberId?: string;            // OG メンバーのみ。公式チャンネルの場合は undefined
  title: string;                // 動画タイトル
  publishedAt: string;          // YouTube の公開日時（ISO 8601）
  capturedAt: string;           // 収集日時（ISO 8601）
};
```

**Neon カラムとのマッピング:**

| Neon カラム | YoutubePost フィールド | 型 | 備考 |
| --- | --- | --- | --- |
| `videoId` | `videoId` | VARCHAR(50) PK | YouTube 動画 ID（通常 11 文字） |
| `channelType` | `channelType` | VARCHAR(20) | `'official'` または `'og'` |
| `memberId` | `memberId` | VARCHAR(50) NULL | OG メンバーのみ。公式チャンネルは NULL |
| `title` | `title` | VARCHAR(500) | 動画タイトル |
| `publishedAt` | `publishedAt` | TIMESTAMPTZ(3) | YouTube 公開日時 |
| `capturedAt` | `capturedAt` | TIMESTAMPTZ(3) | 収集日時 |

**YouTube Data API v3 フィールドとのマッピング:**

| API フィールド | YoutubePost フィールド | 備考 |
| --- | --- | --- |
| `snippet.resourceId.videoId` | `videoId` | PlaylistItems の動画 ID |
| `snippet.title` | `title` | 動画タイトル |
| `snippet.publishedAt` | `publishedAt` | ISO 8601 形式 |
| —（収集時刻） | `capturedAt` | `new Date().toISOString()` |

---

## 4. メンバー JSON 設計（`data/members.json`）

OG メンバーのチャンネル情報は `members.json` の `youtubeChannel` フィールドで管理する。

```ts
type YoutubeChannel = {
  handle: string;      // YouTube ハンドル（例: "@nakazawa-yuko"）
  playlistId: string;  // アップロード済み動画の PlaylistItems API で使用するプレイリスト ID
};
```

**フィールド仕様:**

| フィールド | 型 | 説明 |
| --- | --- | --- |
| `handle` | string | `@` を含む YouTube ハンドル（例: `@nakazawayuko_official`） |
| `playlistId` | string | チャンネルのアップロード動画プレイリスト ID（`UC` を `UU` に置換して取得） |

**設定例:**

```json
{
  "id": "nakazawa-yuko",
  "youtubeChannel": {
    "handle": "@nakazawayuko_official",
    "playlistId": "UUxxxxxxxxxxxxxxxxxxxxxx"
  }
}
```

YouTube チャンネルを持たない OG メンバーおよび現役メンバーは `youtubeChannel` フィールドを持たない。

**設定経路（#1105）**: メンバー詳細編集フォーム（デスクトップモード）の「YouTubeチャンネル名」欄にハンドル（`@〜`）を入力して保存すると、`PUT /api/members` がサーバー側で `fetchPlaylistId`（channels API の `forHandle`）を呼び出して `playlistId` を解決し、Vercel Blob の `members.json` に書き戻す。ハンドルを空欄にして保存するとフィールドが削除される。

---

## 5. 運用ルール

### 収集対象

| チャンネル種別 | 対象 | `channelType` |
| --- | --- | --- |
| 公式チャンネル | `@MorningMusume_official` 等 | `'official'` |
| OG 個人チャンネル | `youtubeChannel` が設定された OG メンバー | `'og'` |

### 重複防止

- `videoId` が PK のため、同一動画は upsert 不要・INSERT 無視でよい
- 収集スクリプトは差分のみ INSERT する（既存 `videoId` を事前取得してスキップ）

### 保持件数

| チャンネル種別 | 保持ルール |
| --- | --- |
| 公式チャンネル | 全件保持（差分追加のみ・削除しない） |
| OG 個人チャンネル | メンバーごとに最新 5 件のみ保持（収集時に、今回取得分へ含まれない古い投稿を削除） |

### API クォータ管理

- YouTube Data API v3 は 1 日 10,000 ユニットのクォータ制限がある
- PlaylistItems の `list` は 1 コールあたり 1 ユニット（最大 50 件取得）
- クォータ超過時は `quotaExceeded` エラーを検出して処理を中断する（開発ノート #19 参照）

---

## 6. アプリからの参照（読み取り関数）

UI（#1106）からの `youtube_posts` 読み取りは `lib/youtube.ts` の以下の関数で行う。
いずれも `unstable_cache`（revalidate: 1800 秒）でラップし、DB エラー時は空データにフォールバックする（`lib/tiktok.ts` と同パターン）。

| 関数 | 用途 | クエリ内容 |
| --- | --- | --- |
| `getOfficialYoutubePosts(page, pageSize)` | 公式チャンネルページ（`/youtube/official`） | `channelType = 'official'` を `publishedAt` 降順で skip/take ページング。総件数も返す |
| `getOgYoutubePosts(memberId)` | OGチャンネルページ（`/youtube/og/[memberId]`） | `channelType = 'og' AND memberId` を `publishedAt` 降順で最大 5 件 |
| `getRecentYoutubePosts()` | TOP 新着投稿セクション | `channelType` ごとに `youtube_sync_status` の該当ウィンドウ（§6.2）で `capturedAt` を判定し、official/ogいずれかの条件に合致する投稿を `publishedAt` 降順で最大 10 件 |
| `getAllOfficialYoutubePosts()` | メンバー詳細画面（後述§6.1） | `channelType = 'official'` の全件を `publishedAt` 降順で取得（ページングなし。公式チャンネルは全件保持方針のため`getTikTokPosts()`と同様の特性） |

画面仕様の詳細は [youtube-screen-design.mdx](/design/sns/youtube-screen-design) を参照。

---

### 6.1 メンバー判定（公式チャンネル動画、都度計算・#9）

公式チャンネル動画に写っている（言及されている）メンバーを判定し、メンバー詳細画面（`/members/{id}`）から参照できるようにする。TikTok（`lib/tiktok-attribution.ts`）と異なり、判定結果はDBに保存せず、表示のたびに都度計算する。

#### 判定関数

- ファイル: `lib/youtube-attribution.ts`
- 関数: `attributeYoutubeVideoToMembers(posts: YoutubePost[], members: Member[]): (YoutubePost & { mentionedMemberIds: string[] })[]`
- 判定対象: `title`（動画タイトル全文）。TikTokのようなハッシュタグ抽出は行わない
- **マッチング方式はフルネーム一致のみ**（`member.name` の完全一致、および空白除去版との一致）。TikTokにある姓・名それぞれの部分一致フォールバックは採用しない。TikTokの判定対象はハッシュタグ限定テキストのため姓・名の部分一致でも誤検出が少ないが、YouTube公式チャンネル動画のタイトルは自由文であり、姓のみ一致では無関係な文脈（ツアー名・企画名等）との誤検出リスクが高いため
- TikTok同様の文字揺れ吸収（`﨑`→`崎` 等の異体字正規化）は流用する
- **返り値の型は実行時限定の拡張型**（`YoutubePost & { mentionedMemberIds: string[] }`）とする。TikTokの`TikTokPost.mentionedMemberIds`はNeonカラムとして永続化されるが、本機能はDBに保存しないため、`YoutubePost`型本体（§3）には`mentionedMemberIds`フィールドを追加しない（存在しないカラムを型定義に持たせると誤解を招くため）

#### データ取得

- `getAllOfficialYoutubePosts()`（§6）で公式チャンネル動画を全件取得し、`attributeYoutubeVideoToMembers()` でメンバー詳細画面表示時にフィルタする

画面仕様の詳細は [screen-design.mdx §3 メンバー詳細画面](/design/common/screen-design) を参照。

---

### 6.2 新着表示の判定基準（TOP新着投稿セクション、#1450）

`getRecentYoutubePosts()`（§6）は、[Amebaブログ機能 データ設計書](/design/sns/ameba-data-design) §8 の新着ウィンドウ方式（#1387）と同じ考え方で、固定の日付境界ではなく実行区間ベースのウィンドウを使う。

#### 型定義

```ts
type YoutubeSyncStatus = {
  officialLastSyncedAt: string;         // official の直近の実行完了時刻（手動実行含む）
  officialLastAutoSyncedAt: string;     // official の直近の「定期実行」の完了時刻
  officialPreviousAutoSyncedAt: string; // official のその1つ前の「定期実行」の完了時刻
  ogLastSyncedAt: string;               // og の直近の実行完了時刻（手動実行含む）
  ogLastAutoSyncedAt: string;           // og の直近の「定期実行」の完了時刻
  ogPreviousAutoSyncedAt: string;       // og のその1つ前の「定期実行」の完了時刻
};
```

**Neon カラムとのマッピング（`youtube_sync_status`、1行のみ・`id=1` に upsert）:**

| Neon カラム | フィールド | 型 | 備考 |
| --- | --- | --- | --- |
| `id` | — | INTEGER PK | 常に `1` 固定（単一行テーブル） |
| `officialLastSyncedAt` | `officialLastSyncedAt` | TIMESTAMPTZ(3) | official 用 |
| `officialLastAutoSyncedAt` | `officialLastAutoSyncedAt` | TIMESTAMPTZ(3) | official 用 |
| `officialPreviousAutoSyncedAt` | `officialPreviousAutoSyncedAt` | TIMESTAMPTZ(3) | official 用 |
| `ogLastSyncedAt` | `ogLastSyncedAt` | TIMESTAMPTZ(3) | og 用 |
| `ogLastAutoSyncedAt` | `ogLastAutoSyncedAt` | TIMESTAMPTZ(3) | og 用 |
| `ogPreviousAutoSyncedAt` | `ogPreviousAutoSyncedAt` | TIMESTAMPTZ(3) | og 用 |

Ameba は `ameba/sync-status.json`（Blob）で管理するのに対し、YouTube は投稿データ自体が Neon 常駐（`youtube_posts`）のため、同期ステータスも Neon テーブルとして管理する。単一行テーブルである点は Ameba と同じだが、後述のとおり official/og でカラムを分けている点が Ameba とは異なる。

#### 書き込みロジック

`official`（`collect-youtube-official.ts`）・`og`（`collect-youtube-og.ts`）は、`updateYoutubeSyncStatus(channelType)` にそれぞれ `'official'` / `'og'` を渡して呼び出し、**自分のチャンネル用カラムのみ**を更新する（もう一方のチャンネルのカラムには触れない）。

- 実行トリガーの判定: `GITHUB_EVENT_NAME === 'schedule'`（Ameba と同一ロジック、`scripts/workflow/sync-status.ts` を参照）
- **定期実行の場合**: 呼び出したチャンネルの `xxxLastAutoSyncedAt` を今回の実行時刻に更新し、旧 `xxxLastAutoSyncedAt` を新しい `xxxPreviousAutoSyncedAt` にスライドさせる
- **手動実行の場合**: 呼び出したチャンネルの `xxxLastAutoSyncedAt`・`xxxPreviousAutoSyncedAt` は据え置き、`xxxLastSyncedAt` のみ今回の実行時刻に更新する
- 初回実行時（テーブルが空）は、呼び出したチャンネル・呼び出していないチャンネルの双方が今回の実行時刻にフォールバックする（もう一方のチャンネルの初回実行時にも改めて自分の値へ更新される）

**なぜチャンネルごとにカラムを分けているか（#1450 のレビューで発覚した回帰、重要）**: 当初の実装は Ameba に倣い、official/og の両方が同じ3カラム（`lastSyncedAt`/`lastAutoSyncedAt`/`previousAutoSyncedAt`）を共有スライドする設計だった。Ameba は現役・OGを1つのワークフロー（`sync-status.ts`）内でまとめて処理するため単一の3カラムで問題ないが、YouTube は独立した2つのスケジュール（`collect-youtube-official.yml` 毎日 / `collect-youtube-og.yml` `OG_SYNC_WEEKDAYS`で指定した曜日）が別々のタイミングで同じカラムをスライドさせる。このため、例えば official 実行完了の約30分後に og が実行完了すると、`previousAutoSyncedAt` が og の実行時刻まで押し上げられ、official 自身が数十秒前に収集した投稿の `capturedAt` がウィンドウ下限を下回って新着から除外されるという、#1450 が修正しようとした不具合と同種の回帰が別経路で再発することが判明した。単一ウィンドウの共有をやめ、official/og それぞれが自分の実行履歴のみに基づいてウィンドウをスライドさせることで解消した。

#### 表示側のウィンドウ計算

`getRecentYoutubePosts()`（`lib/youtube.ts`）は `youtube_sync_status` から official 用ウィンドウ `[officialPreviousAutoSyncedAt, officialLastSyncedAt]` と og 用ウィンドウ `[ogPreviousAutoSyncedAt, ogLastSyncedAt]` をそれぞれ取得し、`channelType = 'official' AND capturedAt がofficial用ウィンドウ内` または `channelType = 'og' AND capturedAt がog用ウィンドウ内` のいずれかに合致するレコードを `publishedAt` 降順で最大10件返す（Prismaの`OR`条件）。

- **改訂の経緯（#1408 → #1450 → #1450レビュー修正）**: #1408時点の実装は「全チャンネル横断で最新の `capturedAt` を1件取得し、そのJST日付と同日のレコードを新着とする」方式だった。`official`（毎日収集）と`og`（`OG_SYNC_WEEKDAYS`で週1回程度に制限）のスケジュールが近接しているため、どちらか一方の実行が日付をまたいで遅延すると、もう一方の当日分投稿が新着判定から漏れる不具合があった（#1450）。その修正として導入した単一の共有ウィンドウ `[previousAutoSyncedAt, lastSyncedAt]` 方式も、上述の「書き込みロジック」の理由により同種の不具合を再発したため、チャンネルごとに独立したウィンドウで判定する現行方式に改めた
- ウィンドウ開始点に固定の「24時間前」ではなく実際の `xxxPreviousAutoSyncedAt` を使う理由は Ameba と同じ（定期実行自体の遅延に対して頑健にするため）
- `youtube_sync_status` にレコードが存在しない場合（初回定期実行前）は空配列を返す

初回の定期実行を待たずに表示確認したい場合、Ameba（`scripts/patch/seed-ameba-sync-status.ts`）と同様に `youtube_sync_status` へ暫定データを投入する一回限りのパッチスクリプト（`scripts/patch/seed-youtube-sync-status.ts`）を用意している（MorningStatusApp側、#1450）。official/og双方の初期値を同じ相対時間幅でバックフィルする。

---

## 7. 環境変数

| 変数名 | 用途 |
| --- | --- |
| `YOUTUBE_API_KEY` | YouTube Data API v3 キー。Repository secret に保存 |
| `DATABASE_URL` | Neon への接続 URL。Repository secret に保存 |
| `MEMBERS_BLOB_URL` | Vercel Blob のメンバーデータ URL（OG 収集で `youtubeChannel` を参照）。Repository secret に保存 |
| `OG_SYNC_WEEKDAYS` | OG 収集を実行する曜日番号（カンマ区切り、デフォルト: `1`）。Repository variable に保存（非機密な設定値のため、#1347） |

---

## 改訂履歴

| 版 | 更新日 | 変更内容 |
| --- | --- | --- |
| 1.8 | 2026-07-30 | §6.2 の `youtube_sync_status` をofficial/og独立カラム構成に変更（PRレビューで、単一ウィンドウ共有だと一方の実行完了がもう一方のウィンドウ下限を押し上げる回帰を検出したため）。`getRecentYoutubePosts()` の判定を `channelType` ごとの独立ウィンドウ + `OR` 条件に変更。§2・§6 の記述も追従（#1450） |
| 1.7 | 2026-07-30 | §2 に `youtube_sync_status` を追加、§6.2 新着表示の判定基準（実行区間ベースのウィンドウ）を追加。§6 `getRecentYoutubePosts()` の判定方式を更新。初回定期実行前の表示確認用パッチスクリプトが必要になる旨を追記（#1450） |
| 1.6 | 2026-07-29 | §6 に `getAllOfficialYoutubePosts()` を追加、§6.1 公式チャンネル動画のメンバー判定（都度計算）の設計を追加（#9） |
| 1.5 | 2026-07-18 | `OG_SYNC_WEEKDAYS` の保存先を Repository secret から Repository variable に修正（非機密な設定値のため、#1347） |
| 1.4 | 2026-06-12 | §6 アプリからの参照（読み取り関数）を追加、環境変数を §7 に繰り下げ（#1106） |
| 1.3 | 2026-06-11 | OG 向け収集ワークフロー（`collect-youtube-og`・#1104）を反映: 更新タイミング・保持件数・環境変数を追記 |
| 1.2 | 2026-06-11 | `youtube_posts` の更新タイミングを実際のワークフロー名（`collect-youtube-official`）に更新（#1103） |
| 1.1 | 2026-06-10 | API フィールドマッピングの動画 ID 取得元を `snippet.resourceId.videoId` に修正（PR #1113 レビュー） |
| 1.0 | 2026-06-09 | 初版作成（#1101） |
