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 より優先して描画します。
処理フロー
- SQS イベントを受信し、
blogBuilderPayloadSchemaでバリデーションする blog_idでblogを取得する。存在しない場合は例外にせず終了する- 既存
body_htmlから、生成済みサムネイル(<img class="nb-thumbnail">)と関連情報セクション(<section class="nb-related">)を抽出する(再ビルド時の引き継ぎ用) - カテゴリから記事テンプレートを決定する
- テンプレートへ本文・サマリー・着順テーブル・サムネイルを流し込んで HTML を構築する
- 関連情報セクションを組み立て、ルート
</div>の直前へ挿入する- レースに紐づくブログはレースカードを先頭に置く(再ビルド時は作り直す)
- 引き継いだセクションに類似ブログカードがあればそのまま後ろに残す
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度だけクライアントを破棄して張り直し、 同じ実行内でやり直します。