コンテンツにスキップ

blog_builder Lambda 概要

SQS(blog-builder キュー)のメッセージをトリガーに、blog_rewriter / blog_generation が生成した ブログ本文を、カテゴリに応じた記事テンプレートへ流し込んで HTML を構築し、 DocumentDB の blog コレクションへ body_html として保存する Lambda 関数です。 保存時に status を draft に更新します。

テンプレートは oddspark-static-pages の実コンポーネント・実 SCSS をバンドルして SSR します。 自作の再現コンポーネントは持たず、本物をそのまま使います。


技術スタック

項目 内容
ランタイム Node.js 22
言語 TypeScript
レンダリング react-dom/server の renderToStaticMarkup()
バリデーション zod(blogBuilderPayloadSchema)
DB クライアント mongodb(公式ドライバ)
バンドル esbuild + sass(SCSS モジュールは esbuild-sass-plugin + postcss-modules)

article_builder と同じく、バックエンドで TypeScript 実装の Lambda です。


トリガー

送信元 タイミング
blog_rewriter Lambda 過去ブログのリライトが完了した時
blog_generation Lambda レース回顧・予想の書き下ろしが完了した時

カテゴリと記事テンプレートの対応

カテゴリ テンプレート 元デザイン
予想 / レース予想・検証 予想記事 proto-pages/keirin/kr_news_art
レース回顧 / 予想結果 / レース結果・回顧 レース結果(着順テーブル(全着順) + 結果・払戻金一覧ボタン付き) proto-pages/keirin/kr_news_art?articleType=race-result
上記以外・未設定(インタビュー等) テンプレート proto-pages/keiba/kb_news_art

着順テーブル(race_result_table)のデータは blog_rewriter / blog_generation が race_result から 生成して SQS ペイロードで渡します。レースに紐づかないブログでは省略され、テーブルなしでビルドされます。


本文のブロック構造(body_blocks)

生成側の AI は、記事の内容に合わせて本文を「ブロック」の配列として構造化します(content.body_blocks)。 本 Lambda はブロックを oddspark の実コンポーネントで描画します。

ブロック 用途 描画コンポーネント
section_title 章見出し Heading (h3 / 18)
paragraph 段落(<a> <strong> <br/> 可) Text
numbered_sections 番号付き小見出し + 本文(長文の分割) Text bold + Text
info_list 【日時】【場所】等 → ラベルと値(【】は排除) Heading + Text bold/Text
prediction 予想印(◎○▲△→本命/対抗/単穴/連下/注目/注意) PredictionPlayerLabel + TextList
interview_qa インタビューの質疑応答 Text bold + Text
list 箇条書き TextList
image 記事内画像(src は絶対 URL 化済み) img
divider 区切り線 SectionDivider
  • 予想印の番号(馬番・車番)は本文に明記されている場合のみ設定される。空の場合は番号バッジを表示しない
  • AI 出力がブロック JSON として解釈できない場合、blog_rewriter は content.body_html(リライト済み HTML)を送り、.nb-body-html のタイポグラフィで表示する
  • prediction ブロックの mark アイコン(legend-*)は esbuild の ICON_ALLOWLIST に含めてある。新しいアイコンを使う場合は追記が必要

SQS イベントペイロード(受信)

{
  "blog_id": "string",
  "race_id": "string",
  "racing_type": "horse_racing",
  "category": "string",
  "race_result_table": { "columns": [], "rows": [], "result_url": "string" },
  "race_info": {
    "race_id": "string",
    "race_date": "2026-12-21",
    "track_name": "string",
    "race_number": "12R",
    "race_name": "string",
    "grade": "Jpn3",
    "start_time": "15:40"
  },
  "content": {
    "title": "string",
    "body_html": "string",
    "body_blocks": [],
    "summary": ["string"]
  }
}
フィールド データ型 必須 備考
blog_id string ◯ 更新対象の blog の _id
race_id string | null ◯ 紐づくレースの _id。レースに紐づかない場合は null
racing_type enum | null ◯ 競技種別。タグ表示に使う
category string 記事テンプレートの選択に使う
race_result_table object | null 着順テーブル(全着順)。レース結果記事でのみ表示
race_info object | null 関連情報に表示するレースカードの内容
content object ◯ 本文。body_html か body_blocks のいずれかが必須

content.body_blocks が設定されている場合は content.body_html より優先して描画します。


処理フロー

  1. SQS イベントを受信し、blogBuilderPayloadSchema でバリデーションする
  2. blog_id で blog を取得する。存在しない場合は例外にせず終了する
  3. 既存 body_html から、生成済みサムネイル(<img class="nb-thumbnail">)と関連情報セクション(<section class="nb-related">)を抽出する(再ビルド時の引き継ぎ用)
  4. カテゴリから記事テンプレートを決定する
  5. テンプレートへ本文・サマリー・着順テーブル・サムネイルを流し込んで HTML を構築する
  6. 関連情報セクションを組み立て、ルート </div> の直前へ挿入する
    • レースに紐づくブログはレースカードを先頭に置く(再ビルド時は作り直す)
    • 引き継いだセクションに類似ブログカードがあればそのまま後ろに残す
  7. body_html / status=draft / updated_at を保存する
flowchart TD
    Gen([blog_rewriter / blog_generation]) -->|SQS| Start
    Start[SQS トリガー受信] --> Parse[zod でペイロードを検証]
    Parse -->|invalid| Error[例外を throw]
    Parse -->|valid| Fetch[blog_id で blog を検索]
    Fetch -->|存在しない| Skip[ログを出して正常終了]
    Fetch -->|存在する| Carry[既存 body_html からサムネイル・関連情報を抽出]
    Carry --> Template[カテゴリから記事テンプレートを決定]
    Template --> Render[renderToStaticMarkup で HTML を構築]
    Render --> Related[関連情報セクションを末尾へ挿入]
    Related --> Save[body_html を保存し status を draft に更新]
    Save --> Success[正常終了]
    Error -->|最終試行| Discard[generating の blog を物理削除して終了]

デザインの再現方式(vendor/oddspark)

sibling リポジトリ oddspark-static-pages のコンポーネント・SCSS・アセットを vendor/oddspark/ に同期し、esbuild + sass でバンドルして記事エリア(NewsBody 相当)を静的 HTML 化します。

項目 内容
同期 npm run sync:oddspark(scripts/sync-oddspark.mjs)。デザイン側が更新されたら再実行してコミットする
vendor/ コミット対象(約 10MB)。Docker ビルドコンテキストが apps/blog_builder のみのため sibling リポジトリを参照できない
SCSS ルートファイルへデザイントークン(styles/common.scss)を自動注入する
画像・SVG base64 data URI としてインライン化(Lambda は静的アセットを配信できないため)
アイコン CSS 全 171 個のうち ICON_ALLOWLIST で実際に使うものだけ残す。新しいアイコンを使う場合は追記が必要
next/image・next/link 素の <img> / <a> に差し替える
出力 dist/index.js(Lambda ハンドラ)+ dist/index.css(記事エリア用 CSS、約 184KB)。実行時に CSS を <style> として body_html 先頭に埋め込む

静的 HTML ゆえの制約

  • JS のインタラクション(ブックマークボタン等)は動作しない(見た目のみ)
  • useGetMediaQuery は SSR では PC/SP とも false → PC 相当のレイアウトで固定
  • フォント(Noto Sans JP 等)は Google Fonts への外部参照

body_html の後加工との互換性

他サービスが body_html を正規表現で加工するため、次のマークアップ契約を維持する必要があります。

要素 用途
<div class="nb-body"> 本文コンテナ(thumbnail_generation の挿入アンカー)
<img class="nb-thumbnail" src="..."> サムネイル(再ビルド時に抽出して引き継ぐ)
ルート要素の末尾 </div> 関連情報はルート閉じタグ直前に挿入する
<section class="nb-related"> 関連情報セクション(再ビルド時に抽出して引き継ぐ)

関連情報セクションのマークアップは odds_poc_app 側(utils/related_info.py)の生成と揃えています。 ビルド時 CSS のクラス名ハッシュに依存しないよう、インラインスタイルで自己完結させています。 変更時は両方を直す必要があります。


エラーハンドリング

状況 挙動
最終試行以外での失敗 例外を投げ直し、SQS の再配信に復旧を委ねる
最終試行での失敗 status=generating の blog を物理削除し、例外を投げ直さずに終了する

DocumentDB の接続系エラーでは、article_builder と同様に1度だけクライアントを破棄して張り直し、 同じ実行内でやり直します。