Skip to content

blog_builder Lambda Overview

Triggered by messages on the SQS blog-builder queue, this Lambda pours the blog body produced by blog_rewriter / blog_generation into an article template chosen by category, builds the HTML, and stores it as body_html on the blog collection in DocumentDB, setting status to draft.

The templates bundle and server-render the real components and real SCSS from oddspark-static-pages. There are no hand-written look-alike components — the originals are used as-is.


Tech stack

Item Details
Runtime Node.js 22
Language TypeScript
Rendering renderToStaticMarkup() from react-dom/server
Validation zod (blogBuilderPayloadSchema)
DB client mongodb (the official driver)
Bundling esbuild + sass (SCSS modules via esbuild-sass-plugin + postcss-modules)

Like article_builder, this is a TypeScript Lambda in an otherwise Python backend.


Trigger

Sender When
blog_rewriter Lambda When a past-blog rewrite completes
blog_generation Lambda When a race recap / prediction has been written

Categories and article templates

Category Template Original design
予想 / レース予想・検証 Prediction article proto-pages/keirin/kr_news_art
レース回顧 / 予想結果 / レース結果・回顧 Race result (full finishing-order table + results/payouts button) proto-pages/keirin/kr_news_art?articleType=race-result
Anything else / unset (interviews, …) Generic template proto-pages/keiba/kb_news_art

The finishing-order table data (race_result_table) is built from race_result by blog_rewriter / blog_generation and passed in the SQS payload. For blogs not tied to a race it is omitted and the article is built without a table.


Body block structure (body_blocks)

The generating AI structures the body as an array of "blocks" matched to the content (content.body_blocks). This Lambda renders each block with the real oddspark components.

Block Purpose Component
section_title Section heading Heading (h3 / 18)
paragraph Paragraph (allows <a> <strong> <br/>) Text
numbered_sections Numbered subheading + body (splitting long text) Text bold + Text
info_list 【date】【place】 etc. → label and value (brackets stripped) Heading + Text bold/Text
prediction Prediction marks (◎○▲△ → 本命/対抗/単穴/連下/注目/注意) PredictionPlayerLabel + TextList
interview_qa Interview questions and answers Text bold + Text
list Bullet list TextList
image In-article image (src already absolutized) img
divider Divider SectionDivider
  • A prediction number (horse / car number) is set only when the body states it explicitly; otherwise no number badge is rendered
  • When the AI output cannot be parsed as block JSON, blog_rewriter sends content.body_html (the rewritten HTML) instead, rendered with the .nb-body-html typography
  • The legend-* mark icons used by prediction blocks are listed in esbuild's ICON_ALLOWLIST; new icons must be added there

SQS event payload (input)

{
  "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"]
  }
}
Field Type Required Notes
blog_id string ◯ _id of the blog to update
race_id string | null ◯ _id of the linked race; null when not tied to a race
racing_type enum | null ◯ Racing type, used for the tag
category string Drives template selection
race_result_table object | null Full finishing-order table; shown only on race result articles
race_info object | null Contents of the race card shown in the related-information section
content object ◯ The body. Either body_html or body_blocks is required

When content.body_blocks is present it takes precedence over content.body_html.


Processing flow

  1. Receive the SQS event and validate it with blogBuilderPayloadSchema
  2. Fetch the blog by blog_id. If it does not exist, exit without raising
  3. Extract the existing thumbnail (<img class="nb-thumbnail">) and related-information section (<section class="nb-related">) from the current body_html, to carry them over on rebuild
  4. Pick the article template from the category
  5. Render the template with the body, summary, finishing-order table and thumbnail
  6. Assemble the related-information section and insert it just before the root </div>
    • Race-linked blogs get the race card first (rebuilt each time)
    • Similar-blog cards from the carried-over section are kept after it
  7. Store body_html, status=draft and updated_at
flowchart TD
    Gen([blog_rewriter / blog_generation]) -->|SQS| Start
    Start[Receive SQS trigger] --> Parse[Validate payload with zod]
    Parse -->|invalid| Error[Throw]
    Parse -->|valid| Fetch[Look up blog by blog_id]
    Fetch -->|not found| Skip[Log and finish normally]
    Fetch -->|found| Carry[Extract thumbnail / related section from existing body_html]
    Carry --> Template[Pick template from category]
    Template --> Render[Render with renderToStaticMarkup]
    Render --> Related[Insert the related-information section at the end]
    Related --> Save[Store body_html and set status=draft]
    Save --> Success[Finish]
    Error -->|final attempt| Discard[Hard-delete the generating blog and finish]

How the design is reproduced (vendor/oddspark)

Components, SCSS and assets from the sibling repository oddspark-static-pages are synced into vendor/oddspark/, bundled with esbuild + sass, and the article area (the NewsBody equivalent) is rendered to static HTML.

Item Details
Sync npm run sync:oddspark (scripts/sync-oddspark.mjs). Re-run and commit whenever the design side changes
vendor/ Committed (~10 MB). The Docker build context is only apps/blog_builder, so the sibling repository is unreachable at build time
SCSS Design tokens (styles/common.scss) are auto-injected into root files
Images / SVG Inlined as base64 data URIs (a Lambda cannot serve static assets)
Icon CSS Only the icons in ICON_ALLOWLIST are kept out of all 171; new icons must be added there
next/image, next/link Replaced with plain <img> / <a>
Output dist/index.js (the handler) and dist/index.css (article-area CSS, ~184 KB). At runtime the CSS is embedded as a <style> at the top of body_html

Consequences of static HTML

  • JS interactions (bookmark buttons, …) do not work — appearance only
  • useGetMediaQuery is false for both PC and SP under SSR, so the layout is fixed to the PC variant
  • Fonts (Noto Sans JP, …) are external references to Google Fonts

Compatibility with body_html post-processing

Other services post-process body_html with regular expressions, so this markup contract must hold.

Element Purpose
<div class="nb-body"> Body container (insertion anchor for thumbnail_generation)
<img class="nb-thumbnail" src="..."> Thumbnail (extracted and carried over on rebuild)
Trailing </div> of the root element Related information is inserted just before it
<section class="nb-related"> Related-information section (extracted and carried over on rebuild)

The related-information markup is kept identical to the one odds_poc_app generates (utils/related_info.py). It is self-contained with inline styles so it does not depend on build-time CSS class hashes. Both sides must be changed together.


Error handling

Situation Behaviour
Failure on a non-final attempt Re-throw and let SQS redelivery recover
Failure on the final attempt Hard-delete the status=generating blog and finish without re-throwing

On connection-class DocumentDB errors the client is discarded and re-established once and the build is retried in the same invocation, just like article_builder.