---
title: "Instagram投稿機能 データ設計書"
---

最終更新: 2026-08-12

## 1. 概要

Instagram 投稿のメタデータを取得し、サムネイル画像を直接参照する方式をとる。
当初の「画像キャプチャによる Vercel Blob 保存」から、ストレージ容量削減と実行速度向上のため「外部 URL 参照」へ移行した。

---

## 2. Blob ファイル一覧

| ファイルパス | 概要 | 更新タイミング |
| --- | --- | --- |
| `instagram/{memberId}/posts.json` | メンバー単位の全投稿メタデータ | `collect-instagram-posts` ワークフロー実行時（差分追加） |
| `instagram/sync-status.json` | 同期実行状態（同期状況画面向け） | `collect-instagram-posts` ワークフロー実行時（毎回上書き） |
| `instagram/daily-new-posts.json` | 当日新規取得投稿一覧（トップ画面向け） | `collect-instagram-posts` ワークフロー実行時（毎回上書き） |

> **注意**: `instagram/{memberId}/{postId}.jpg` への画像保存は廃止された。

> `{postId}` は投稿 URL のパス末尾（`/p/xxxxxxxx/` の `xxxxxxxx` 部分）を使用する。

---

## 3. 型定義

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

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

```ts
type InstagramPost = {
  postUrl: string;        // 投稿URL (https://www.instagram.com/p/xxxx/)
  thumbnailUrl?: string;  // サムネイル画像 URL (外部参照: OGP og:image)。Live投稿は undefined
  imageBlobUrl?: string;  // [レガシー] Vercel Blob 上の画像 URL
  isLive?: boolean;       // Live アーカイブ投稿フラグ。true の場合 thumbnailUrl は存在しない
  isReel?: boolean;       // Reel投稿フラグ。true の場合UI上に「Reel」ラベルを表示する
  caption: string;        // キャプション全文（空文字の場合あり。null は空文字に正規化）
  capturedAt: string;     // 取得日時（ISO 8601）
  memberId: string;       // メンバーID
  instagramUrl?: string;  // 投稿元の Instagram アカウント URL（複数アカウント対応）
};
```

**caption の取得**: OGP `og:description` メタタグから取得し、`cleanCaption()` で不要な接頭辞を除去した後**全文**（複数行を含む）を保存する。取得できなかった場合は空文字列。Zod スキーマ側で null/undefined を空文字列に正規化するため、レガシーデータの null caption も安全に読み込める。

> **1行目のみ保存する仕様からの変更（#10）**: 従来は `cleanCaption()` が1行目のみを切り出して保存していたが、ハッシュタグは通常キャプション末尾（2行目以降）に書かれるため、1行目のみの保存ではメンバー判定（§8）に必要な情報が失われていた。全文保存に変更したことで、収集済みの caption からハッシュタグを抽出できるようになる。
> - **過去に収集済みの投稿（1行目のみの旧データ）への遡及的な再取得は行わない**。本変更以降に新規収集する投稿から全文保存が適用される
> - UI表示側（`InstagramPostCard` 等）は既存の CSS（`whiteSpace: nowrap` + `textOverflow: ellipsis`）で1行に省略表示しているため、caption が長くなっても表示上の変更は不要

`instagram/{memberId}/posts.json` の形式: `InstagramPost[]`

### `InstagramSyncStatus`（同期状態）

```ts
type InstagramSyncStatus = {
  lastSyncedAt: string;              // 最終同期日時（ISO 8601）
  sessionStatus: 'valid' | 'expired'; // Instagram セッションの有効状態
  capturedMembersCount: number;       // 今回実行で取得できたメンバー数
  totalTargetMembersCount: number;    // 取得対象メンバー総数
};
```

`instagram/sync-status.json` の形式: `InstagramSyncStatus`

### `DailyNewPost`（当日新規投稿）

```ts
type DailyNewPost = {
  postUrl: string;        // 投稿URL
  thumbnailUrl?: string;  // サムネイル画像 URL (外部参照: OGP og:image)。Live投稿は undefined
  imageBlobUrl?: string;  // [レガシー] Vercel Blob 上の画像 URL
  isLive?: boolean;       // Live アーカイブ投稿フラグ
  isReel?: boolean;       // Reel投稿フラグ。true の場合UI上に「Reel」ラベルを表示する
  caption: string;        // キャプション全文（null は空文字に正規化）
  capturedAt: string;     // 取得日時（ISO 8601）
  memberId: string;       // メンバーID
  instagramUrl?: string;  // 投稿元の Instagram アカウント URL（複数アカウント対応）
  memberName: string;     // メンバー表示名（トップ画面で別途Blob参照しないよう埋め込む）
};
```

`instagram/daily-new-posts.json` の形式: `DailyNewPost[]`

---

## 4. 運用ルール

### 収集対象

- `officialSns` に `instagram.com` を含む URL が登録されており、`active: true` のメンバーが対象
- `active: false` のエントリは収集をスキップする
- **公式グループアカウント**: `constants/instagram-official-accounts.ts` の `OFFICIAL_INSTAGRAM_ACCOUNTS` に定義されたアカウントも収集する（現在: `morningmusume_official`）
  - 投稿は `instagram/{account.id}/posts.json` に保存する（例: `instagram/morningmusume-official/posts.json`）
  - `memberId` にはアカウントID（例: `morningmusume-official`）を使用する
  - UI では Instagram 一覧ページの「公式」セクションに表示する

### 重複防止

- 収集スクリプト実行時に既存の `posts.json` を読み込み、取得済みの `postUrl` はスキップする
- `daily-new-posts.json` には当該実行で初めて取得した投稿のみを記録する

### 投稿削除時の扱い

- Blob 上のデータは削除しない（履歴として保持）
- 削除された投稿の `postUrl` にアクセスすると 404 になる点を UI 側で考慮すること

### Session 失効時

- Instagram へのアクセスが以下のいずれかのパターンにリダイレクトされた場合、セッション失効と判定する（#749）:
  - `/accounts/login` を含む URL
  - `/challenge/` を含む URL
  - `/privacy/checks/` を含む URL
  - `/accounts/suspended/` を含む URL
  - `/accounts/one_tap/` を含む URL
  - `is_from_rle` パラメータを含む URL
- セッション失効と判定した場合、`sessionStatus: 'expired'` を `sync-status.json` に書き込み、非ゼロ終了コードで終了する
- GitHub Actions が失敗扱いになり、リポジトリオーナーにメール通知が届く

---

## 5. 認証状態のセットアップ手順

`INSTAGRAM_AUTH_STATE` Secret は、初回セットアップ時および Session 失効時に以下の手順で登録する。

> **前提**: このスクリプトは **macOS** で実行すること。Windows では Playwright のブラウザ起動が動作しない。

```bash
# 0. playwright のブラウザをインストール（初回のみ）
bunx playwright install chromium

# 1. ヘッドフルブラウザで Instagram にログインし、認証状態を保存する
bun run instagram:auth
# → auth_state.json が生成される（60 秒以内に手動ログインすること）

# 2. 生成された auth_state.json を GitHub Actions Secret に登録する
gh secret set INSTAGRAM_AUTH_STATE < auth_state.json

# 3. auth_state.json を削除する（機密情報のため必ず削除）
rm auth_state.json
```

> **注意**: `auth_state.json` には Cookie 等の認証情報が含まれるため、Git にコミットしてはならない（`.gitignore` 済み）。

---

## 6. 環境変数

| 変数名 | 用途 |
| --- | --- |
| `INSTAGRAM_AUTH_STATE` | Playwright 認証状態（JSON文字列）。Repository secret に保存 |
| `BLOB_READ_WRITE_TOKEN` | Vercel Blob への読み書きトークン（既存） |
| `MEMBERS_BLOB_URL` | メンバーデータの Blob URL（既存） |

> `INSTAGRAM_SYNC_STATUS_BLOB_URL` / `INSTAGRAM_DAILY_NEW_POSTS_BLOB_URL` は、`MEMBERS_BLOB_URL` からパスを導出するフォールバック実装に切り替え済みのため廃止（#1351）。`lib/blob.ts` の `getInstagramSyncStatusFromBlob` / `getDailyNewPostsFromBlob` を参照。

---

## 7. 画面設計（UI）

### 7-1. Instagram投稿一覧画面（`/instagram`）

**概要**: 公式アカウント・メンバー個人アカウントの最新投稿をカバー表示するハブ画面。

| 要素 | 内容 |
| --- | --- |
| ナビゲーション | 「← ホームへ戻る」リンク（`/` へ） |
| タイトル | 「Instagram」 |
| 説明文 | 「公式アカウントおよびメンバーの最新投稿」 |
| 公式アカウントセクション | `OFFICIAL_INSTAGRAM_ACCOUNTS`（複数アカウント対応）ごとの最新投稿カバーをグリッド表示。0件の場合はセクション自体を非表示 |
| メンバー最新投稿セクション | `officialSns` に `active: true` かつ `instagram.com` を含むURLを登録している全メンバーの最新投稿カバーをグリッド表示（`sortMembers` で現役→OG順に並び替え、#1491） |
| 著作権表記 | 各セクションに `DisclaimerNote`（「（画像は参考表示：権利は投稿者に帰属しています）」）を表示 |
| データなし表示 | メンバー最新投稿が0件の場合「投稿データが見つかりませんでした。」の glass カードを表示 |

**カバーカード（`InstagramCoverCard`）の表示内容**:

| 項目 | 内容 |
| --- | --- |
| サムネイル | `thumbnailUrl`。Live投稿で `thumbnailUrl` がない場合は専用プレースホルダー表示 |
| アカウント/メンバー名 | `memberName` |
| クリック動作 | `latestPostUrl`（Instagram投稿ページ）を新しいタブで開く |

### 7-2. Instagramアカウント別画面（`/instagram/[accountId]`）

**概要**: 公式アカウント1件分の投稿一覧を表示する画面。`accountId` は `OFFICIAL_INSTAGRAM_ACCOUNTS` の `id`。

| 要素 | 内容 |
| --- | --- |
| ナビゲーション | 「← Instagram へ戻る」リンク（`/instagram` へ） |
| タイトル | `{account.name}` |
| アカウントリンク | `@{username} ↗`（Instagram公式アカウントページへの外部リンク、ユーザー名を抽出できた場合のみ） |
| 著作権表記 | `DisclaimerNote` |
| 投稿一覧 | `OfficialInstagramSection`（`hideHeader` 指定）で投稿を表示。`isReel: true` の投稿はサムネイル左下に「Reel」ラベルを表示する |

**アクセス制御**: `accountId` が `OFFICIAL_INSTAGRAM_ACCOUNTS` に存在しない場合は `notFound()`。

### 7-3. メンバー詳細画面（`/members/{id}`）: 公式アカウント投稿の表示（#10）

**概要**: メンバー詳細画面の `InstagramSection`（`components/member-detail/InstagramSection.tsx`）は、メンバー本人の `officialSns` 登録アカウントの投稿に加え、公式アカウント（`OFFICIAL_INSTAGRAM_ACCOUNTS`）の投稿のうち当該メンバーが判定された投稿（§8）も表示する。

**アカウントバケットの列挙対象拡張**:

- 従来: `officialSns` に登録された、メンバー自身の Instagram アカウント（`active: true` かつ `instagram.com` を含む URL）のみを列挙
- 拡張後: 上記に加えて、`OFFICIAL_INSTAGRAM_ACCOUNTS` のうち、そのメンバーが言及されている投稿（`mentionedMemberIds` に該当・サムネイルあり）を1件以上持つアカウントもバケットとして列挙する
- 判定結果（言及メンバーIDの算出）はページ側（サーバーコンポーネント）で計算済みの投稿配列を渡す形とし、`InstagramSection` 自身は判定ロジックを持たない（既存の「表示専用コンポーネント」という役割分担を維持する）

**表示順・ラベル**:

| 項目 | 内容 |
| --- | --- |
| 表示順 | 本人の `officialSns` 登録アカウント（登録順）を先に表示し、公式アカウントでの言及投稿はその後ろに表示する。公式アカウントバケットが複数該当する場合は `OFFICIAL_INSTAGRAM_ACCOUNTS` の定義順に列挙する |
| ラベル | 本人アカウントは従来通り `@{username} ↗`。公式アカウントバケットは本人のアカウントと混同しないよう `{account.name}（公式アカウントでの言及） @{username} ↗` の形式で表示する |
| リンク先 | 他のバケットと挙動を統一するため、`/instagram/{accountId}`（アプリ内の公式アカウント一覧画面）ではなく、他バケット同様に外部の Instagram 投稿ページ（`account.instagramUrl`）へのリンクとする |
| バケット数が1件以下の場合 | 従来通り「単一アカウント: 全投稿を一覧表示」の表示（アカウント見出しなし）にフォールバックする |

---

## 8. メンバー判定（公式アカウント投稿、都度計算・#10）

公式アカウント（`OFFICIAL_INSTAGRAM_ACCOUNTS`）の投稿に写っている（言及されている）メンバーを判定し、メンバー詳細画面（§7-3）から参照できるようにする。YouTube（[YouTube投稿一覧機能 データ設計書](/design/sns/youtube-data-design) §6.1）と同様、判定結果は保存せず表示のたびに都度計算する。Instagram の投稿データは Neon ではなく Vercel Blob（`instagram/{accountId}/posts.json`）に保存されているため、判定結果を永続化するには過去投稿分を含めた書き戻しバッチが別途必要になる。過去投稿は §3 の caption 全文化以前のデータでハッシュタグ判定の対象にできないため、書き戻しの実益が薄く、都度計算を採用する。

### 判定関数

- ファイル: `lib/instagram-attribution.ts`（新設）
- 関数: `attributeInstagramPostToMembers(posts: InstagramPost[], members: Member[]): (InstagramPost & { mentionedMemberIds: string[] })[]`
- 判定対象は **TikTok（`lib/tiktok-attribution.ts`）と同じくキャプション内のハッシュタグ（`#` で始まるトークン）のみ**とする。ハッシュタグ抽出の正規表現 `#[^\s#]+` は空白・改行いずれの区切りにも対応しており、Instagram のハッシュタグが本文と別ブロック（複数行）に書かれる書式差異があっても TikTok 版をそのまま流用できる
- マッチング規則も TikTok 版を踏襲する: フルネーム（正規化後・空白除去版含む）の部分一致を優先し、一致しない場合は姓・名（2文字以上のもの）の部分一致にフォールバックする
- TikTok・YouTube 同様の異体字正規化（`﨑`→`崎`・`髙`→`高`・`栁`→`柳`・`𠮷`→`吉`）を流用する
- **返り値の型は実行時限定の拡張型**（`InstagramPost & { mentionedMemberIds: string[] }`）とする。都度計算のため `InstagramPost` 型本体（§3）には `mentionedMemberIds` フィールドを追加しない（Blob 上の実データに存在しないフィールドを型定義に持たせると誤解を招くため）

### データ取得

- `lib/blob.ts` に `getAllOfficialInstagramPosts()` を新設する。`OFFICIAL_INSTAGRAM_ACCOUNTS` の各アカウントに対して `getInstagramPostsFromBlob(account.id)` を呼び出し、結果を結合して返す（`getAllOfficialYoutubePosts()` の Instagram 版）
- メンバー詳細画面（`app/members/[id]/page.tsx`）で `getAllOfficialInstagramPosts()` の結果を `attributeInstagramPostToMembers()` で判定し、当該メンバーの `mentionedMemberIds` に一致する投稿のみを本人の `getInstagramPostsFromBlob(id)` の結果とマージして `InstagramSection` に渡す（`youtubePosts` と同じ「ページ側で判定・フィルタ済みの配列を渡す」パターン）

---

## 改訂履歴

| 版 | 更新日 | 変更内容 |
| --- | --- | --- |
| 1.10 | 2026-08-12 | SNS投稿一覧のルール統一（#1491）: §7-1 メンバー最新投稿セクションの表示順に `sortMembers` による現役→OG順の並び替えを追加。見出し・公式アカウント→メンバーの表示順は既に統一ルールに適合済みのため変更なし |
| 1.9 | 2026-07-29 | §3 の caption 保存範囲を1行目のみ→全文に変更（#10）。§7-3・§8 を追加し、公式アカウント投稿のメンバー判定（都度計算）を設計（#10） |
| 1.8 | 2026-07-22 | `InstagramPost`・`DailyNewPost` に `isReel` フィールドを追記。§7-2 に Reel ラベル表示を追記（#1373） |
| 1.7 | 2026-07-18 | 画面設計セクション（§7）を追加。`/instagram`・`/instagram/[accountId]` 画面の表示内容を反映（#1366） |
| 1.6 | 2026-07-18 | `INSTAGRAM_SYNC_STATUS_BLOB_URL` / `INSTAGRAM_DAILY_NEW_POSTS_BLOB_URL` 環境変数の廃止を反映（#1351） |
| 1.5 | 2026-06-20 | `InstagramPost`・`DailyNewPost` に `isLive`・`instagramUrl` フィールドを追記（#1166, #1186） |
| 1.4 | 2026-06-15 | 公式グループアカウント収集を追加（#1143）。`OFFICIAL_INSTAGRAM_ACCOUNTS` 定数・収集ルール・UI 表示（「公式」セクション）を追記 |
| 1.3 | 2026-04-30 | caption の取得元（OGP og:description）と null 正規化を明記、セッション失効判定パターンを詳細化（#732, #749, #754） |
| 1.2 | 2026-04-29 | サムネイル取得方式（外部参照）への移行対応（#751） |
| 1.1 | 2026-04-26 | 認証状態セットアップ手順を追加（#720） |
| 1.0 | 2026-04-26 | 初版作成（#715） |
