Automated Blog Pipeline with Gemini: Architecture of What We Built

How our Node.js pipeline turns a brief into an SEO-checked Korean blog draft: autocomplete keywords, competitor research, schema-bound writing, lint, images.

By the ailog editors · Published Oct 1, 2026 · 9 min read · How we work
In short
  • We built a roughly 520-line Node.js pipeline (nine small modules, one dependency: Playwright) that turns a one-paragraph brief into a Korean blog draft with 5–10 images and a preview page.
  • The stages run in order: autocomplete keyword expansion, top-post research (structure only), schema-bound writing with Gemini, an SEO lint with one automatic rewrite, then images and the HTML preview.
  • Honesty lives in code, not just the prompt. Facts may come only from fetched source pages, brand facts only from one fixed JSON file, and every section must cite real source numbers. Anything unsupported goes into an unsupported_claims field.
  • Five complete test runs cost $0.086–$0.112 each and took 45–82 seconds.
  • It does not publish yet. It writes local drafts and previews, and the one login attempt for the posting browser timed out. Auto-posting is the next step.

We built this for our client-facing blog: a small Korean web studio’s blog on Naver, the search portal most Korean customers use. The goal is modest. Every few hours the pipeline should produce one useful, accurate, search-friendly post a human can review and post. This write-up covers how the pieces fit together, with real excerpts from the code. Identifying strings are replaced with placeholders.

The pipeline at a glance

The entry point is a single command that takes a brief file:

node src/run.mjs briefs/<topic>.json

A brief is small: a slug, a topic, an “angle” (who the reader is and what we want to say), a list of official source URLs, and a few seed search phrases. Our first test brief covered integrating a Korean payment gateway. It had three official documentation pages as sources and four seed phrases.

Stage Module What it does Paid?
Keywords keywords.mjs Expands seeds through the portal’s autocomplete No
Keyword pick write.mjs Gemini picks one main and 3–5 sub keywords from the real candidates Yes (small)
Research research.mjs Reads the current top 5 blog posts for that keyword and summarises their coverage Yes (small)
Writing write.mjs Gemini writes the post as JSON against a strict schema Yes
Lint + retry seo.mjs, run.mjs Checks keyword placement and length, rewrites once if needed Maybe
Images images.mjs HTML cards and real-page screenshots via Playwright, plus one AI hero image Hero only
Preview preview.mjs Writes index.html and post.json in a timestamped folder No

Every run writes a run.json with the chosen keywords, lint results, character count, image count, the model’s unsupported claims, and a per-call cost log. All the numbers in this article come from those files.

Keywords from autocomplete, not from the model

We didn’t want the model inventing what people search for. The keyword module asks the portal’s public autocomplete endpoint about each seed, adding a handful of modifiers (“cost”, “how to”, “integration”, “agency”, “recommendation” in Korean). It then filters out navigational junk:

const BAD = /채용|로그인|고객센터|주식|해킹|주가|전화번호|앱다운|다운로드|위키|나무위키|연봉|면접|홈택스|관리자센터|센터$/;
export async function candidates(seeds) {
  const mods = ['', ' 비용', ' 방법', ' 연동', ' 업체', ' 추천'];
  const set = new Set();
  for (const s of seeds) for (const m of mods) {
    for (const k of await suggest(s + m)) if (!BAD.test(k)) set.add(k.trim());
    await new Promise((r) => setTimeout(r, 250));
  }
  return [...set];
}

The regex drops jobs, login, customer-service, stock-price, download and wiki queries. None of those come from people who might hire a web studio. A small Gemini call then chooses a main keyword and sub keywords, and the schema forces them to be picked verbatim from that list. The prompt also tells it to avoid consumer queries such as checking payment history. We want the business owner who is building something, not the shopper.

Research the top posts for coverage, never for sentences

Next, research.mjs searches the portal’s blog tab for the main keyword. It takes the first five post URLs and fetches the mobile version of each, with a randomised 0.7–1.3 second pause between requests. From each page it pulls only structural signals: title, character count, how many image modules the editor inserted, and a guess at subheadings (short lines that don’t end like a sentence). A 1,500-character excerpt goes to Gemini so it can tell what each post covers. That excerpt is removed before anything is saved.

The research call returns a design brief, not text: reader intent, topics most top posts cover, gaps they miss, title patterns, a suggested outline, and target length and image count. The schema descriptions carry the rules. Here is one of them, translated: “recent issues seen in the top posts (not verified — do not state as fact before checking official sources)”.

That last field mattered on the first real run. The top five posts for our keyword ran from 856 to 2,933 characters with 3 to 27 images. Three of the five weren’t integration guides at all. They were consumer-side news about a security incident. The research step flagged the incident as a trending issue. The writer then left it out of the post and logged it as unsupported, because none of our official sources mentioned it.

Writing with a schema and a short leash

The writing call is one generateContent request with responseMimeType: 'application/json' and a responseSchema. The prompt gives the model four blocks: BRAND (facts about the studio from a fixed JSON file), the chosen KEYWORDS, the RESEARCH brief, and SOURCES (plain text of each fetched official page, numbered). The rules that matter for honesty, translated from the prompt:

- Facts (fees, procedures, policies, features) only from the SOURCES text. Numbers exactly as written.
  If it is not in the sources, do not write it — list it in unsupported_claims.
- Do not copy source sentences; summarise and restructure.
- Facts about <STUDIO> only from BRAND. No customer reviews, performance numbers or invented cases.
- Promotion only in the final cta, plainly.

A prompt alone is a request, not a guarantee, so the code checks two things. First, every section has to say which sources it used. After parsing, sections that cite nothing, or cite a source number that doesn’t exist, are dropped:

const valid = new Set(post.sources.map((s) => s.id));
post.sections = post.sections.filter((s) =>
  s.source_ids?.length && s.source_ids.every((id) => valid.has(id))
  || /<STUDIO>|체크|정리|마무리/.test(s.heading));

The regex at the end is an escape hatch we should be upfront about. A section whose heading names the studio or reads like a checklist or wrap-up survives without a valid citation. The prompt allows one closing section of practical tips drawn from the studio’s own experience. So that section is checked against BRAND by the prompt only, not by code. Second, the schema makes unsupported_claims a required field. The model must say what it left out. Here is what came back on four of the runs, paraphrased:

  • the general credit-card fee rate, because the fee page only listed some special rates
  • the list of documents needed to sign up, which the sources didn’t state
  • how many business days the gateway’s review takes
  • the security incident from the research step

Those lists are our review checklist. They show where a human editor might want to add a verified fact, or where the model was tempted to fill in a gap.

Two more guardrails sit outside the model. The brand file is the only source of studio facts: prices, services, past work. It is edited by hand. The preview also ends every post with a fixed disclosure line from that file, which says the studio itself wrote the post. The model can’t remove it. It isn’t part of the schema at all. The AI hero image is captioned as AI-generated by the preview code, not by the model.

The detailed lessons about the schema itself, including what minItems fixed, are in our structured-output notes.

Lint, then exactly one rewrite

The model often misses mechanical SEO targets. A plain function checks the draft:

  • the main keyword sits within the first 10 characters of the title
  • the keyword appears in the first intro line
  • it appears in at least 2 subheadings and 4–9 times in the body
  • the body runs 1,500–3,600 characters
  • a tag holds the keyword
  • at least one table and one checklist exist

If anything fails, run.mjs calls the writer once more. It passes along the list of problems and reuses the keyword pick and research, so they aren’t paid for twice:

let post = await writePost(brief);
let seo = lint(post);
if (!seo.ok) {
  const retry = await writePost({ ...brief,
    research: { pick: { main: post.main_keyword, subs: post.sub_keywords }, research: post.research },
    angle: brief.angle + `\n\n[Problems in the previous draft — must fix] ${seo.problems.join(', ')} ...` });
  const s2 = lint(retry);
  if (s2.problems.length <= seo.problems.length) { post = retry; seo = s2; }
}

It runs one retry, not a loop. A loop would turn a lint rule into an open-ended bill. The results were mixed, and the preview shows the lint outcome at the top so the reviewer sees it. Of the four runs that had the lint step, two passed. One still came up short at 1,449 characters after its retry. The other used the keyword three times instead of four. The length problem was fixed in the schema, not by retrying. That story is in the structured-output article.

Images, preview and the posting browser

Each post gets:

  • a 1080×1080 title card
  • a table card and a checklist card
  • one summary card for each section that has neither
  • screenshots of the studio’s matching service page and one past project
  • one AI-generated hero image

Everything except the hero is HTML rendered by headless Chromium, so it costs nothing per image. The details are in rendering title cards with Playwright. The final test run produced 10 images. The hero image is the main cost: about $0.072 of the $0.0995 that run cost. How the pipeline stops itself from spending too much is in our per-run cost cap.

preview.mjs writes a Naver-styled index.html and a post.json into a folder named after the date, time and slug. That is the whole output today: files on disk for a person to read.

Posting is planned around a persistent browser profile instead of an API. As far as we know, the portal has no public blog-posting API, and we didn’t want to script a password into anything. So naver-login.mjs opens real Chrome with a dedicated profile directory. A human logs in once by hand. The script polls the profile’s cookies until the session cookies appear, then records the blog address and exits:

const ctx = await chromium.launchPersistentContext(PROFILE,
  { channel: 'chrome', headless: false, viewport: null });
await page.goto('<portal-login-url>');
while (Date.now() < deadline) {           // 15-minute window
  const ck = await ctx.cookies('<portal-origin>');
  if (ck.some((c) => c.name === '<session-cookie-1>') && ck.some((c) => c.name === '<session-cookie-2>')) { ok = true; break; }
  await new Promise((r) => setTimeout(r, 2000));
}

The profile directory is git-ignored. The plan is for a later script to reuse that profile and save posts as drafts in the blog editor, with public posting turned on only after explicit human approval. To be clear about where things stand: the one login attempt so far ended with LOGIN_TIMEOUT, and that posting script doesn’t exist yet. Scheduling, a topic ledger to prevent repeats, and an AI-news collector are also in the plan document, not in the code.

What we’d do differently

  • Treat the escape-hatch regex as a smell. Sections exempt from citation checks should be flagged in the preview, not silently allowed through.
  • Fail closed on research. If the search page changes its markup, topPosts returns nothing and the writer goes ahead without a brief. We’d rather see a loud warning.
  • Save the source text with the run. We keep source URLs but not the fetched text. Reviewing a claim later means fetching a page that may have changed.
  • Keep the lint visible, not just automated. Showing “SEO: needs work — keyword 3 times (4–9 recommended)” in the preview header turned out to be more useful than a second retry would have been.
  • Count before you trust the estimate. Our plan budgeted about $0.005 of text per post. The measured text cost was $0.015–$0.041, three to eight times higher. The LLM API cost calculator is a quick way to sanity-check that kind of figure before building.
Sources
  1. Gemini API reference: models.generateContent (GenerationConfig, UsageMetadata)
  2. Gemini API: Structured outputs
  3. Gemini Developer API pricing
  4. Playwright: BrowserType.launchPersistentContext

Related