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-htmltypography - The
legend-*mark icons used bypredictionblocks are listed in esbuild'sICON_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
- Receive the SQS event and validate it with
blogBuilderPayloadSchema - Fetch the
blogbyblog_id. If it does not exist, exit without raising - Extract the existing thumbnail (
<img class="nb-thumbnail">) and related-information section (<section class="nb-related">) from the currentbody_html, to carry them over on rebuild - Pick the article template from the category
- Render the template with the body, summary, finishing-order table and thumbnail
- 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
- Store
body_html,status=draftandupdated_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
useGetMediaQueryis 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.