メインコンテンツまでスキップ

ダイレクト広告仕様

広告主がセルフサービスで広告を出稿できるダイレクト広告販売の、配置・課金モデル・配信ロジック・画像仕様を定義する。

本仕様は ビジネスモデル・課金設計 の「収益源の多様化」方針を実装に落とし込んだものであり、親 Issue #101 配下の実装 sub-issue(#102 / #104 / #106 など)の前提として作成する。AdSense 仕様(広告配置仕様)の上位互換として動作し、在庫不足時は AdSense にフォールバックする。

1. 概要

  • 広告プロダクト: 自社運用のダイレクト広告(広告主が Stripe で一括前払い購入)
  • 最初は日本のみ試験適用#101 の決定事項)
  • 料金: 国ごとに異なる月額料金(初期は日本のみ、5 万円/枠・月)。position 間は均一価格
  • AdSense との関係: ダイレクト広告を優先配信し、在庫不足分は AdSense にフォールバック
  • 実装方針: 既存の AdSlot コンポーネントを拡張し、layout="card" の枠を直広告 / AdSense のどちらで埋めるかを配信ロジックで判定する

2. 表示対象ユーザー

AdSense と同じ useShouldShowAds() フックを流用する。

ユーザー状態planダイレクト広告AdSense
匿名(未ログイン)表示表示(フォールバック)
ログイン済 Freefree表示表示(フォールバック)
ログイン済 Plusplus非表示非表示
ログイン済 Propro非表示非表示

Free / 匿名ユーザーへの配信順位は ダイレクト広告 → AdSense の二段構え。

3. 対象ページとスロット配置

AdSense と同じく /search/videos/[videoId] の 2 ページのみ。各ページ 2 slot × 4 position = 1 country/month あたり計 16 枠 を販売する。

3.1 /search(検索結果ページ)

slot ID位置position 数レイアウト
search-result-midroll結果グリッドの N 動画行ごと(列数別、§3.3)4grid-cols-2 sm:grid-cols-3 md:grid-cols-4
search-result-bottom結果末尾、ページネーション直前4同上

3.2 /videos/[videoId](動画詳細ページ)

slot ID位置position 数レイアウト
video-detail-below-playerYouTube プレイヤーと字幕ビューアの間4grid-cols-2 sm:grid-cols-3 md:grid-cols-4
video-detail-below-subtitle字幕ビューア末尾、関連動画の上4同上

AdSense 仕様書との整合: /videos/[videoId] の旧仕様(レスポンシブディスプレイ 1 枠・マルチプレックス 1 枠)は廃止し、/search と同じ 4-position カードグリッドに統一する。詳細は ads-specification.md §3.2 を参照。

3.3 レイアウト: 固定 4 position・CSS grid 折り返し

「列数 N に合わせて N 枠レンダー」ではなく、固定 4 枠を常にレンダーし CSS grid で折り返す

画面幅表示行備考
デスクトップ(≥ 768px)1 行 × 4 枠position 1, 2, 3, 4 が横並び
タブレット(640〜767px)2 行(3 + 1)position 4 が 2 行目
モバイル(< 640px)2 行(2 + 2)position 3, 4 が 2 行目

position 1〜4 は画面幅に関わらずすべて描画されるため、広告主視点で impression が公平になる。

3.4 midroll 発火頻度の列数別調整

固定 4 枠折り返しのまま「3 動画行ごとに midroll」ルールを各デバイスに適用すると、モバイルの動画:広告比が崩れるため、列数に応じて発火間隔を調整する。

列数midroll 発火間隔1 PV あたりの広告密度
4col(デスクトップ)3 動画行ごと約 12 動画ごとに 1 midroll
3col(タブレット)4 動画行ごと約 12 動画ごとに 1 midroll
2col(モバイル)6 動画行ごと約 12 動画ごとに 1 midroll

実装は useGridColumns() の戻り値を key にした定数テーブルで分岐する(apps/web/src/widgets/search-results/ui/search-results-grid.tsxAD_ROW_INTERVAL)。

3.5 空状態

/search で 0 件ヒットの場合、既存の AdSense 挙動に従い no-results カードの直下に 1 行分(列数分のカード型広告) のみ表示する。*-bottom は表示しない。

4. 画像仕様

項目仕様
アスペクト比(推奨)16:9(VideoCard のサムネイルと同一)
画像の収め方object-fit: contain + ニュートラル背景色。広告主が意図した画像が切り取られずに表示される
ファイル形式JPG / PNG / WebP のいずれか
最大ファイルサイズ500 KB
推奨解像度1280 × 720 px(YouTube サムネイル標準)
最小解像度640 × 360 px(4col デスクトップ 1 枠の 2 倍相当)

なぜ contain を選ぶか

  • 広告主が制作した画像をそのまま表示することが最優先(cover だと重要な情報がクロップされるリスク)
  • Google Display Network も contain 相当の挙動がデフォルト
  • aspect-ratio がずれた場合の余白は背景色(bg-muted 相当)で吸収する

4.5 広告主表示名仕様

広告カード上部に表示される「誰が出している広告か」を示す文字列。Google AdSense のインフィード広告(JetBrains on Qiita など)における広告主名表示に相当する。

項目仕様
最大文字数30 文字(全角・半角問わず)
改行不可(1 行で表示)
文字種任意(日本語 / 英字 / 数字 / 一部記号)
表示位置card レイアウト上部 / banner レイアウト左上
DB カラムad_reservations.advertiser_name

禁則文言

ユーザーの錯誤を招く表現は入稿時バリデーションで弾く:

  • 「広告」「AD」「PR」「スポンサード」「Sponsored」等、広告ラベル自体を示す文言
  • 「公式」「正規代理店」等、媒体(Subseek)との提携関係があるかのような表現
  • 媒体名そのもの("Subseek" 等)

なぜ広告主表示名を必須にするか

  1. ユーザーへの透明性: 広告主が誰かを明示することで、AdSense ポリシーと同等の開示水準を担保する
  2. 広告主のブランディング機会: クリエイティブ画像と別に広告主名がテキストで出ることで、第一想起を取りやすくする
  3. 既存の AdSlot プレースホルダ構造との整合: apps/web/src/features/ads/ui/ad-slot/ad-slot.tsx は既に「広告主名 + 画像」のレイアウトを採用しているため、直広告側もこの構造に揃える

5. 予約モデル

5.1 在庫単位

slot × position × country × month の 4 次元を UNIQUE とする。例:

  • search-result-midroll × position 1 × JP × 2026-05
  • search-result-midroll × position 2 × JP × 2026-05(別広告主が購入可)

DB 実装上の注記

  • DB の UNIQUE 制約は (slot_id, position, country, start_month) のみで、end_month は含まない
  • 契約期間は複数月にまたがるため、期間重複の厳密チェックはアプリ層で実施する(例: 2026-05〜2026-07 と 2026-06〜2026-06 の衝突判定)
  • SQLite で範囲制約を表現するのが複雑なのが理由。start_month が同一の重複だけは DB 制約で確実に弾かれる
  • アプリ層の重複判定は getOccupiedPositions(slotId, country, startMonth, endMonth)apps/api/src/features/ad-reservation/)が責務を負う

5.2 購入フロー

広告主は以下を選択・入稿して購入する:

① 掲載内容の選択

  1. slot: 4 slot(2 ページ × 2 slot)から選択
  2. country: 現在は JP のみ
  3. 掲載開始月: 審査時間を確保するため、最短でも翌月から開始可能(リードタイム 1 ヶ月)
  4. 掲載期間: 1〜12 ヶ月(最大 12 ヶ月)

② クリエイティブの入稿

  1. 広告主表示名: 広告カード上部に表示される広告主名(§4.5 参照)
  2. 広告画像: §4 の画像仕様に準拠
  3. リダイレクト URL: 広告クリック時の遷移先(http(s) スキーム必須、§7.1 で自社計測 API 経由に変換される)

③ 広告主情報(表示には出ないが必須、advertiser_profiles マスタ)

広告主情報は advertiser_profiles テーブルにエンティティとして保存される。1 user が複数プロフィールを保有でき(個人/法人の使い分け、代理店的な運用)、出稿フォームは以下のいずれかを選ぶ:

  • 既存プロフィールから選択(リピート出稿)
  • 新規プロフィール作成

予約確定時に profile の内容を ad_reservationssnapshot としてコピーし、以降プロフィール側が更新されても既存予約は不変(契約時点の事業者情報を特商法対応として固定)。

必要項目:

  1. 事業者情報: 法人名 or 個人名・住所・担当者氏名・担当者メールアドレス
    • 特定商取引法・景品表示法対応、および §10.4 の停止通知メール送信のために必須
  2. 業種カテゴリ: 審査区分のため分類(計 15 種類):
    • IT/SaaS / EC・物販 / 美容・化粧品 / 飲食・食品 / メディア / エンタメ・ゲーム / 教育 / 人材・求人 / 自動車 / 不動産 / 旅行・観光 / 医療・ヘルスケア / 金融 / ギャンブル・ゲーミング / その他
    • 規制業種(美容・食品・医療・金融・ギャンブル等)は、景表法・薬機法・金商法等に基づき許認可を証明する書類の追加提出を求める場合がある
    • 実装: packages/shared/src/ads/business-category/business-category.tsBUSINESS_CATEGORIES(source of truth)
  3. インボイス登録番号(任意): 適格請求書発行事業者の場合のみ

④ 法的同意

  1. 利用規約の同意(§5.4 参照、必須チェック)

position は購入時に選択できない

position 間が均一価格である以上、選択 UI を設けると全員 position 1 を希望して破綻するため、先着順で自動割当する(§6.1 の「position 1 から順に埋める」ルール)。

現時点で割り当たる position の事前プレビュー

ただし出稿フォームでは、「今、この条件で購入すると position X に割り当たります」という情報を表示する。これは広告主が impression 傾向(position 1 が視認性最高)を事前に把握できるようにするため。

  • 国・月・slot を選んだ時点で、該当在庫の next-available position を計算して表示
  • 例: 「JP / 2026-05 / search-result-midroll は position 3 に割り当たります(現在 2 広告が購入済み)」
  • position は購入後に固定される。掲載期間中に他広告主が停止されても繰り上がらない
  • 既に 4 枠すべて埋まっている場合は 購入不可(「この slot は満枠です」と表示)

5.3 決済

Stripe Checkout で一括前払い。サブスクリプション用とは別 Product(#291)。

Checkout 完了 webhook

予約は POST /api/ads/reservations 時点で pending_review / stripe_payment_intent_id = NULL で INSERT され、Stripe Checkout に遷移する。決済成功後の PaymentIntent ID は webhook で後付けする:

項目内容
エンドポイントPOST /api/webhooks/stripe/ad-checkoutapps/api/src/features/ad-reservation/route/ad-checkout-webhook-route.ts
サブスク webhook との関係/api/auth/stripe/webhook(Better Auth プラグイン)とは別エンドポイント。署名 secret は両者で STRIPE_WEBHOOK_SECRET を共有する
受け取るイベントcheckout.session.completed のみ(他は 200 で素通し)
処理metadata.ad_reservation_id で予約を特定し、stripe_payment_intent_idWHERE id = ? AND stripe_payment_intent_id IS NULL で書き込む(idempotent)
status 遷移webhook では行わない。pending_review のまま据え置き、admin の手動審査で active に上げる(§10.2)
失敗時不正署名 / 対象外イベント / metadata 欠落はすべて 200 で握りつぶす(Stripe の自動リトライ抑制)
Stripe Dashboard 設定本番 / staging に同じパスでエンドポイントを登録し checkout.session.completed のみ購読(運用手順は #291 参照)

スコープ外: checkout.session.expired での abandoned 在庫解放、payment_intent.payment_failed 検知、自動 active 化。

5.4 利用規約同意

広告出稿者は、出稿フォームで 利用規約の遵守を明示的にチェックしない限り、購入を完了できない。

  • チェックボックスの文面: 「掲載する広告が本サービスの利用規約に違反していないことを確認しました」
  • チェックなしでは Stripe Checkout に進めない(UI 側で送信ボタンを disable)
  • 規約には以下を含む(§10.4 参照):
    • 禁止される広告内容(反社会的、公序良俗違反、違法、誤認を招く表現等)
    • 違反時の措置(admin による強制停止、返金なし、アカウント停止の可能性)
  • 利用規約の具体的な条項追加は別 Issue で起票し、法務レビューを経て反映する

6. 配信ロジック

6.1 部分充填時の fallback 順位

国 × 月 × slot の 4 position のうち、N 個だけ購入されている場合:

購入数 N表示位置の割当
0全 position AdSense
1position 1 = 直広告 / position 2-4 = AdSense
2position 1-2 = 直広告 / position 3-4 = AdSense
3position 1-3 = 直広告 / position 4 = AdSense
4全 position 直広告

position 1 から順に埋める(フロントエンド配信判定時に該当国・月で active な ad_reservations を作成日昇順で 4 件 take する)。

6.2 同一広告の表示回数上限(1 ページ 2 回ルール)

同一 position の直広告は、1 ページあたり 最大 2 回までしか表示しない。3 回目以降の発火では当該 position を AdSense にフォールバックする。

優先充填順位(カウント上限は各 position 独立):

  1. bottom slot の発火(1 回/ページ) → 直広告(count 1)
  2. midroll 第 1 発火 → 直広告(count 2)
  3. midroll 第 2 発火以降 → AdSense fallback

この仕様は**広告出稿 UI(#104)**で広告主にも明示する:

この広告は 1 検索結果ページあたり最大 2 回表示されます。

6.3 国判定

  • CF-IPCountry ヘッダで判定(Cloudflare Workers が自動付与)
  • packages/shared/src/search/iso-country/iso-country.tsISO_COUNTRY_CODES(249 国)に含まれるコードのみ受理
  • T1(Tor exit node)/ XX(判定不可)/ その他非 ISO コード → 全 position を AdSense fallback

7. クリック計測

7.1 リダイレクト方式

ダイレクト広告の <a> タグは広告主 URL を直接指さず、自社の計測 API 経由でリダイレクトする。

<a href="/api/ad-click/{reservationId}">(広告)</a>

処理フロー(Cloudflare Workers 側):

  1. /api/ad-click/:reservationId が呼ばれる
  2. ad_click_logs に INSERT(reservationId / timestamp / CF-IPCountry / User-Agent)
  3. ad_reservations.redirect_url を取得
  4. HTTP 302 で広告主 URL にリダイレクト

7.2 なぜリダイレクト方式か

項目直リンク + onClick fetchリダイレクト方式(採用)
アドブロッカー耐性❌ uBlock 等で潰される✅ サーバーサイドなので潰されない
離脱時の計測漏れ❌ fetch 完了前離脱で失敗✅ リダイレクト前に必ず記録
bot クリック判定❌ 検知困難✅ UA・レート制限でフィルタ可
レイテンシ✅ 即遷移🟡 1 ホップ(20〜50ms)

数十 ms のレイテンシと引き換えに計測精度を優先する(Google Ads も同方式)。

7.3 インプレッション計測

クリックと別に、広告表示時のインプレッションを日次集計する(#288ad_impression_daily テーブルに記録)。

カウント定義

広告カードが以下のビューアビリティ条件を満たしたとき、1 impression としてカウントする(MRC / IAB 準拠、Google Ads と同等):

  • 面積: ビューポート内に広告カードの 50% 以上が入っている
  • 時間: 上記状態が 1 秒以上継続した

Web 側で IntersectionObserverthreshold: 0.5 を監視し、条件を満たしたタイミングで 1 回だけ計測 API を叩く。条件を満たす前にユーザーが離脱した場合はカウントしない。

同一ページ内の重複扱い

§6.2 の「1 ページ 2 回ルール」により同一広告が同一ページに最大 2 回描画される。この場合、描画機会の数だけカウントする(2 回描画 → 2 impressions)。広告主視点で「露出機会」の公平性を保つため。

bot 除外・重複抑止(MVP 方針)

  • 計測 API 側で User-Agent ベースの bot 判定を行い、明らかな bot(bot/crawler/spider 等の UA パターン)は無視する
  • 同一 IP × reservation × 日の上限は設けない(MVP 段階ではシンプルに保つ)
  • 計測 API は UA を DB に保存しない(ad_impression_daily のスキーマも変更しない)。bot 判定で弾いた時点でカウントから除外するのみ

将来的に生ログが必要になった場合は、ad_impression_logs(raw、UA 含む)+ 日次集計の 2 段構成に拡張可能(#288 のフォローアップで判断)。

取得データ

ad_impression_daily の 1 行に集約する(§10.2 参照):

カラム内容
ad_reservation_id対象予約
dateYYYY-MM-DD(UTC)
countryCF-IPCountry(§6.4 の ISO 国コードのみ受理)
impressionsUPSERT で +1

計測フロー

  1. Web 側で DirectAdSlot が広告を描画
  2. IntersectionObserver が threshold 0.5 / dwell 1s を検知
  3. クライアントから POST /api/ad-impression/:reservationId を発火(body 不要、Cloudflare が CF-IPCountry を自動付与)
  4. API は UA を見て bot なら 204 で終了、通常なら incrementImpression(db, reservationId, country) で UPSERT

クリック計測(§7.1)が GET /api/ad-click/:reservationId リダイレクト方式なのに対し、impression は body-less POST で非同期計測とする(描画継続中のユーザー体験を邪魔しないため)。

8. 「広告」ラベル

AdSense と同一スタイルで、広告カードの上部に「広告」ラベルを表示する(ポリシー準拠、ユーザー錯誤防止)。

  • 日本語: 広告
  • 英語: Advertisement

実装は既存 AdSlot コンポーネント(apps/web/src/features/ads/ui/ad-slot/ad-slot.tsx)のラベル描画ロジックを流用する。

9. 価格モデル

価格管理は Stripe Dashboard で完結させる(サブスク実装 STRIPE_PLUS_MONTHLY_PRICE_ID 等と同じ方式)。独自の価格テーブルを持たず、Stripe の immutable Price 機能で履歴管理・契約時固定を実現する。

基本方針

  • Stripe Dashboard に Product Subseek Direct Ads を作成し、国ごとに Price を作成
    • 例: price_ad_jp_2026h1 = 月額 50,000 円
  • 国コード → 現行 Stripe Price ID の対応は env var で定義:
    • STRIPE_AD_PRICE_JP_ID など(将来国が増えたら env var 追加)
  • position 1〜4 は同一価格(Stripe 側も 1 国 1 Price)
  • 初期値: 日本のみ、月額 5 万円/枠

半年次改定のフロー(4/1・10/1)

Stripe の Price は作成後に unit_amount を変更できない(immutable)ため、改定は以下の手順で行う:

  1. admin が Stripe Dashboard で新 Price を作成(例: price_ad_jp_2026h2
  2. Workers Secret (STRIPE_AD_PRICE_JP_ID) を新 Price ID に切り替え
  3. 旧 Price を archive(新規予約では使われなくなる)

契約時の価格固定

Stripe の Price / PaymentIntent は immutable な記録を持つため、ローカル DB には price を複製保存しない(Stripe-first の原則を徹底)。

  • 予約作成時: fetchAdPrice(country)apps/api/src/features/ad-pricing/)で現行 Stripe Price ID を取得
  • Checkout Session の line_items.price に Price ID + quantity = months を指定
  • Stripe は PaymentIntent.amount として契約時金額(unit_amount × quantity)を永続化
  • 予約行に保存するのは ad_reservations.stripe_payment_intent_id のみ
  • 金額参照が必要になったら stripe.paymentIntents.retrieve() で取得

これにより、半年次改定で新 Price が作成されても既存予約の金額は不変。

技術的裏付け

  • 独自 Turso テーブル (ad_pricing) は持たない — Stripe が真のソース
  • ad_reservations も price カラムを持たない — drift リスクを避ける(サブスク subscriptions と同じ方針)
  • invoice / 売上集計・税計算は Stripe ネイティブ機能に委ねる
  • 過去価格の参照は paymentIntents.retrieve() または prices.retrieve(archived_id)(archive されても削除されない)

10. 実装方針

10.1 コンポーネント

  • 既存 AdSlotapps/web/src/features/ads/ui/ad-slot/ad-slot.tsx)を拡張
  • 新規 DirectAdSlotapps/web/src/features/ads/ui/direct-ad-slot/)を導入し、AdSlot の上に「直広告あれば表示、なければ AdSense にフォールバック」の責務を乗せる
  • 配信判定は Server Component で行い、クライアントには既に decide 済みの結果を渡すことで Hydration コストを抑える

10.2 DB スキーマ(#102 で実装)

ad_slots(slot マスタ、4 行)

  • id (text, PK, AD_SLOT_IDS の enum)
  • display_name (text, admin ダッシュボード用の表示名)
  • created_at / updated_at

CLS 対策の最小表示高さは、§3.3 の 4-position カードグリッド統一により全 slot で共通値(300px)となるため DB カラムでは持たず、packages/shared/src/ads/ad-slot/ad-slot.tsAD_CARD_MIN_HEIGHT 定数を single source of truth とする。

advertiser_profiles(広告主プロフィールマスタ)

広告主情報を予約エンティティから切り出したマスタ。1 user が複数 profile を持てる。

  • id (text, PK、独立 PK、将来 Organization 移行時に所有者を付け替えやすくするため)
  • user_id (FK → users, no cascadeUNIQUE にしない: 複数 profile 保有可)
  • business_legal_name, business_address, contact_name, contact_email
  • business_category (BUSINESS_CATEGORIES enum)
  • invoice_registration_number (nullable)
  • created_at / updated_at

ad_reservations(予約テーブル)

③ 広告主情報は advertiser_profiles から contract snapshot としてコピーされる。profile マスタが後から更新されても、予約行の snapshot は不変(契約時点の情報を特商法対応で固定)。

  • ① 掲載枠: user_id (FK, no cascade), slot_id (FK), position (1〜4), country, start_month, end_month
  • ② クリエイティブ: advertiser_name(§4.5), image_url, redirect_url
  • ③ 広告主情報: advertiser_profile_id (FK → advertiser_profiles、snapshot 元)、および snapshot 用カラム business_legal_name, business_address, contact_name, contact_email, business_categoryBUSINESS_CATEGORIES enum), invoice_registration_number(nullable)
  • ④ 運用: statusAD_RESERVATION_STATUSES enum, default pending_review), stripe_payment_intent_id(契約時金額は Stripe 側で immutable、§9), stopped_at(nullable), stopped_reason(nullable, §10.4)
  • 制約: UNIQUE(slot_id, position, country, start_month)(期間重複はアプリ層で判定、§5.1)

ad_click_logs(クリックログ)

  • id, ad_reservation_id (FK), country, user_agent (nullable), created_at
  • インデックス: (ad_reservation_id, created_at) で CTR クエリを高速化

ad_impression_daily(インプレッション日次集計、#288

  • id, ad_reservation_id (FK), date (YYYY-MM-DD), country, impressions (integer, default 0), timestamps
  • UNIQUE(ad_reservation_id, date, country) で UPSERT による INCREMENT を成立させる

: 価格マスタ(旧 ad_pricing テーブル案)は Stripe Dashboard で管理するため DB テーブルは持たない(§9、#289)。

10.3 API

実装済み

メソッド・パス用途参照
GET /api/ad-click/:reservationIdクリック計測 + 302 で広告主 URL へリダイレクト§7.1
GET /api/ads/serve?slot=X&country=Y&month=Z指定 slot × country × month の position 1〜4 の割当を返す(直広告があれば reservation 情報、無ければ null で AdSense fallback)§6
GET /api/ads/slotsダイレクト広告スロットマスタ 4 件を返す(出稿フォーム選択肢用、public)§3 / #103
GET /api/ads/inventory?slot=X&country=Y&month=Z次に割り当たる position / 既占有 position / 国別月額価格を返す(出稿フォームのプレビュー用、要認証)§5.1 / §5.2 / §9 / #103
GET /api/ads/pricing?country=YStripe Dashboard の国別 Price から現行月額を取得(要認証)§9 / #103
POST /api/ads/reservations予約作成 + Stripe Checkout セッション発行。広告主プロフィール解決(既存 profile 指定 or 新規作成)+ createReservation + createAdCheckoutSession§5 / #103
POST /api/uploads/ad-imageダイレクト広告画像を Cloudflare R2 にアップロードし公開 URL を返す(要認証、multipart/form-data、§4 の制約を適用)§4 / #103

未実装

メソッド・パス用途
POST /api/ad-impression/:reservationIdインプレッション計測(§7.3、body 不要)。UA で bot 判定し、通過時のみ incrementImpression で UPSERT。#288 で実装
POST /api/webhooks/stripe/ad-checkoutStripe Checkout 完了 webhook(§5.3)。checkout.session.completed を受け、metadata.ad_reservation_id 経由で stripe_payment_intent_id を idempotent に後付けする。#291 Phase 2 で実装

運用設定(実装コード外)

  • R2 バケット作成 / 公開 URL(カスタムドメインまたは r2.dev)/Workers Secret R2_PUBLIC_BASE_URL 設定: #321 で管理

10.4 コンテンツポリシーと停止措置

禁止される広告

admin は以下に該当する広告を掲載中でも強制停止できる(#107 で UI 実装):

  • 公序良俗違反、反社会的、違法な内容
  • ユーザーの誤認を招く表現(虚偽の価格・効果・受賞歴等)
  • Subseek のサービス内容・ブランドと著しく矛盾する内容
  • 他の広告や検索結果コンテンツと視覚的に混同させるデザイン
  • その他、Subseek が不適切と判断したもの

停止措置の運用

項目内容
停止の実行admin ページから該当 ad_reservationstatusstopped に更新
返金行わない(一括前払いの前提を維持するため)
通知停止と同時に、停止理由を記載した自動メールを広告主へ送信
表示側の挙動以降の配信対象から除外。該当 position は AdSense にフォールバック
繰り上げ停止された position は繰り上げしない(掲載期間中の position 固定の原則を保つ)

メール通知

  • 送信タイミング: admin が停止操作を実行した直後
  • 送信主体: MVP では admin が手動で送信する運用(メール送信インフラ未導入のため、#107buildStopNotificationEmail による文面生成 + mailto: リンク + クリップボードコピーを提供)。将来 Resend 等の自動送信に切り替える際も同関数を再利用できる。
  • 文面に含める要素:
    • 停止された広告の識別情報(reservationId / slot / position / country / 掲載期間)
    • 停止理由(admin が自由記述で入力)
    • 返金対応がない旨の明示
    • 問い合わせ窓口(コンタクトフォーム)

admin 管理画面(#107

/admin/ads/reservations で運営者が広告を管理する。3 機能を提供する:

機能内容
予約一覧全ユーザーの予約とステータス / 広告主情報 / imp / clk / CTR を表示。ステータス・枠・国でフィルタ可
強制停止ダイアログ停止理由を必須で入力 → POST /api/admin/ads/reservations/:id/stopstatus=stopped に更新 → 続けて停止通知メール文面プレビュー(コピー / mailto)
タイムライン当月から 12 ヶ月先までの slot × position × country グリッドで active 予約の広告主名を俯瞰

/admin/ads/revenue で広告収益(Stripe Payment Intent の amount_received 集計)を月別 / 国別に表示する。サブスク収益とは別カードでグルーピング表示する(仕様書 §10.4)。

規約への反映

本ポリシーは利用規約本文にも明記する必要がある(§5.4 の同意チェックが実効性を持つため)。規約本文の条項追加は別 Issue で起票し、法務レビューを経て反映する。

11. 広告主レポート

広告主マイページ(#105)で、自分が保有する広告予約の配信成果を確認できる。計測 API(§7)が蓄積するデータを可視化する。

11.1 認可条件

  • ログインユーザーは ad_reservations.user_id が自分と一致する予約のみ閲覧可
  • admin ユーザーは /admin の広告一覧ページ#107)で全予約を横断閲覧する(本節のマイページ UI とは別画面)

11.2 予約一覧(マイページ top)

保有する全予約を 1 行 1 予約でリスト表示する。

内容
slot × position × country予約対象(例: search-result-inline / position 2 / JP
掲載期間start_monthend_month(例: 2026-052026-10
残日数現在日から end_month 月末までの日数(掲載終了後は「掲載終了」と表示)
ステータスactive / pending_review / stoppedAD_RESERVATION_STATUSES enum)
累計 impressions掲載期間全体の合計
累計 clicks掲載期間全体の合計
CTRclicks / impressions(impressions 0 のときは

クリックで予約詳細(§11.3)に遷移。

11.3 予約詳細

選択した 1 予約の成果を時系列で表示する。

要素内容
サマリーカード累計 impressions / clicks / CTR / ステータス / 残日数
日次推移グラフ横軸: 掲載期間の日付(start_month の 1 日〜end_month の月末)、縦軸: impressions と clicks の 折れ線(2 系列)
クリエイティブプレビューimage_url / advertiser_name / redirect_url を参考表示

期間フィルタ

掲載期間が予約時に固定されているため、期間フィルタは設けない。グラフは常に掲載期間全体(開始月 1 日〜終了月末)を表示する。

国別分解

直広告は国単位で予約が分かれる(UNIQUE(slot_id, position, country, start_month)、§10.2)。複数国に出稿している広告主は予約一覧で国別に並ぶため、予約詳細内での国別分解は不要。

11.4 データソース

指標取得元
impressions(累計・日次)ad_impression_dailyad_reservation_id で絞り、date で group by
clicks(累計・日次)ad_click_logsad_reservation_id で絞り、date(created_at) で group by
CTRアプリ層で算出(getCtr 相当、apps/api/src/features/ad-impression/model/ad-impression-repository/ad-impression-repository.ts
掲載期間・ステータスad_reservations

11.5 MVP スコープ外

以下は MVP では提供せず、別 Issue で検討:

  • CSV / Excel エクスポート: 日次データのダウンロード機能(#322
  • Stripe 支払金額の表示: Stripe の領収書メールで代替(実装時は paymentIntents.retrieve()
  • 国別分解ビュー(1 予約内): 現状の予約分離モデルでは不要
  • 時刻粒度の推移: 日次粒度で十分(ad_impression_daily のスキーマに合致)

12. admin レポート

admin ユーザー(users.role === "admin")が /admin/ads で全予約の成果を横断閲覧するページ(#108)。広告主マイページ(§11)と違って、user_id によるフィルタをかけずに全予約を対象にする。

12.1 UI 構成

ブロック内容
期間フィルタ開始日・終了日(YYYY-MM-DD)。デフォルトは過去 30 日(今日含む)
サマリーカード期間全体の impressions / clicks / CTR(impressions 0 なら 0)
日次トレンドチャート横軸: 日付、縦軸: impressions / clicks の折れ線(2 系列、recharts
予約別集計テーブル1 行 = 1 予約。列は広告主表示名 / slot × position × country / 掲載期間 / imp / clk / CTR。末尾に合計行

12.2 集計ロジック

API: GET /api/admin/ads/report?startDate=YYYY-MM-DD&endDate=YYYY-MM-DDadminMiddleware でガード、apps/api/src/features/ad-report/

  1. 契約期間が [startDate, endDate] の月と 1 月でも重なる ad_reservations を全件取得(status 問わず)
  2. ad_impression_daily から期間内(date BETWEEN startDate AND endDate)の行を reservationId × date 粒度で SUM
  3. ad_click_logs から期間内(created_at BETWEEN startDate T00:00 AND endDate T23:59:59Z)の行を reservationId × date(created_at) 粒度で COUNT
  4. 予約行に 2・3 の結果を join して reservations[]totals を算出
  5. 2・3 を日付で集約して dailyTrend[](imp or clk が 1 以上の日付のみ昇順)

12.3 MVP スコープ外

  • CSV エクスポート(#322 想定)
  • 広告主別・slot 別サマリーの切替(予約別一覧で代替可)
  • admin による予約の審査・停止操作(#107

13. 既存 AdSense 仕様の更新

以下は本仕様確定に伴い ads-specification.md を更新する。

  • §3.2: /videos/[videoId] のスロット 2 種を、§3.2 と同じ 4-position カードグリッドに統一(旧レスポンシブディスプレイ / マルチプレックスの単枠仕様を廃止)
  • §4.2: video-detail-below-*minHeight を 300px に統一

14. 関連 Issue

この仕様書を書いた Issue

実装 sub-issues(本仕様を参照する)

本仕様確定に伴い更新が必要な既存 Issue / ドキュメント

本仕様書 (#317) クローズ後に以下の本文を書き直す:

  • #101: 「掲載期間: 1ヶ月単位、最大6ヶ月前から予約可能」→「契約期間 1〜12 ヶ月、リードタイム 1 ヶ月」に更新
  • #102: ad_reservations スキーマに広告主表示名・事業者情報カラム群を追加(§10.2 参照)
  • #104: 規約同意チェックボックス(§5.4)・position 割当プレビュー(§5.2)・契約期間 1〜12 ヶ月・「1 ページ最大 2 回表示」注記・広告主表示名入力フィールド(§4.5)・事業者情報入力フォーム(§5.2 ③)の追加
  • #105: 広告主マイページでの停止通知履歴表示
  • #107: admin 強制停止機能・返金なし・自動メール送信(§10.4)
  • #289: 年次 → 半年次価格改定に変更。タイトル・本文の更新

規約本文の改訂(別 Issue で起票)

  • 広告出稿者向け条項の追加(禁止広告の列挙、停止措置・返金なし原則)
  • 法務レビュー必須