---
name: blog-publisher
description: "Publish AI news blog posts to PromptAura with Nano Banana feature images. Pulls candidate stories from the Obsidian vault (ai-updates), writes posts, generates images via Gemini 2.5 Flash Image, uploads to R2, inserts into D1."
version: 1.0.0
author: PPAI
tags: [blog, ai-news, gemini, nano-banana, cloudflare, content-automation]
---

# Blog Publisher

Publishes AI news blog posts to the PromptAura blog automatically.

## Architecture

- **Website**: Astro on Cloudflare Pages at `your-blog.example.com`
- **Blog storage**: Cloudflare D1 database `promptaura-db` (table `blog_post`)
- **Images**: R2 bucket `your-assets-bucket`, served from `your-images.example.com`
- **Image generation**: Gemini 2.5 Flash Image (Nano Banana) via REST API
- **News source**: Obsidian vault — `/root/repo-workspace/vault/30-resources/ai-updates/` (migration from Notion complete as of Jul 2026)
- **Author**: the blog author (writerId `YOUR_WRITER_ID`)

## Workflow

### Phase 1: Load Environment

```bash
set -a; source /root/.env; set +a
```

Required env vars: `GOOGLE_API_KEY`, `CLOUDFLARE_API_TOKEN`, `DISCORD_BOT_TOKEN`

### Phase 2: Query Published Blog Slugs (Deduplication)

Check D1 for existing blog post slugs to avoid duplicates:

```bash
cd /root/repo-workspace/prompt-aura
npx wrangler d1 execute promptaura-db --remote --command "SELECT slug FROM blog_post" 2>&1
```

### Phase 3: Read Candidate Stories from Obsidian Vault

```bash
ls -t /root/repo-workspace/vault/30-resources/ai-updates/*.md | head -20
```

For each file, read the YAML frontmatter + body sections:
- **title** (frontmatter): Article headline
- **## Key Points**: 3-5 bullet highlights
- **## Summary**: 2-3 paragraph summary
- **## Blog Angle**: The PPAI angle for the story — the most valuable field
- **category** (frontmatter): Marketing, Content Creation, Research, Tools, Strategy, News
- **url** (frontmatter): Original article URL
- **status** (frontmatter): `New` → candidate; `Curated` → already in progress; `Published` → already live, skip

### Phase 4: Select Best Story

Selection criteria:
1. Skip any story whose vault headline maps to an existing blog slug (also skip files with `status: Published`)
2. Prefer stories with actionable blog angles (templates, guides, frameworks)
3. Prefer Strategy and Tools categories over pure News
4. Pick the ONE strongest story for the day

### Phase 5: Write Blog Post

Write a full blog post (800-1500 words) in Markdown format. Guidelines:
- **Title**: Catchy, benefit-driven, NOT just the news headline
- **Hook**: Open with a surprising fact or provocative question
- **Structure**: H2 sections with H3 subsections, bullet lists, bold key terms
- **Tone**: Authoritative but conversational — like explaining to a knowledgeable colleague
- **Actionable**: Every post must have a practical "what to do now" section
- **Attribution**: Mention the original source naturally in the text
- **Excerpt**: 1-2 sentence meta description for SEO

### Phase 5.5: Humanizer Pass (MANDATORY)

**Before publishing, run the full humanizer pipeline on the draft.** This is non-negotiable — the blog-publisher's own writing guidelines are not a substitute.

**Step 1:** Load the humanizer skill: `skill_view(name='humanizer')`

**Fallback (2026-08-30 kaizen W35):** If `skill_view` cannot load the humanizer skill (tool unavailable, skill not in job's preloaded list), do NOT abort the whole publish. Degrade gracefully:
1. Retry once with `skill_view(name='humanizer-id')`.
2. If still unavailable, apply the humanizer checklist INLINE: scan the draft for the top AI tells (em-dash overload, "delve", "it's important to note", uniform paragraph rhythm, listicle conclusion), rewrite them manually, vary sentence length, and state in the run report that humanizer ran in degraded inline mode.
3. A degraded humanizer pass is a warning in the ledger notes, not a run failure — only the publish itself failing logs `failed`.
(Aug 27 root cause: pipeline aborted at Phase 5.5 with no fallback → whole day's post lost.)

**Step 2:** Run humanizer in `rewrite` mode with these settings:
- **Voice profile**: `professional` (authoritative but conversational — not stiff)
- **Context preset**: `blog` (full personality injection allowed)
- **Mode**: `rewrite` — return the fully humanized text

**Step 3:** The humanizer pipeline will:
- Scan against all 33 detection patterns (content, language, style, communication, filler)
- Apply the 43-entry replacement table
- Run voice injection (Phase 3): personality, rhythm variation, opinions
- Self-audit (Phase 4): identify residual AI tells and revise
- Final pass (Phase 5): iterate to convergence, max 2 passes

**Step 4:** Use the humanized output as the final post content. Do NOT skip this step — publish the humanized version, not the raw draft.

**Quality gate:** The post must pass the humanizer verification checklist (Section 9 of the humanizer skill) before proceeding to Phase 6. If AI tells remain after humanization, do one more pass. Stop after 2 iterations max.

**For cron jobs:** The cron prompt MUST include an explicit instruction to load and run the humanizer skill between Phase 5 and Phase 6. Example addition to cron prompt:
```
After writing the draft (Phase 5) and BEFORE generating images (Phase 6):
1. Load humanizer skill: skill_view(name='humanizer')
2. Run full humanizer rewrite pipeline on the draft (voice=professional, context=blog)
3. Use the humanized output as the final post content
```

### Phase 6: Generate Feature Image via Nano Banana

Call the Gemini 2.5 Flash Image REST API:

```bash
IMAGE_PROMPT="A modern, minimalist editorial illustration: [THEME DESCRIPTION]. Color palette: deep navy blue background with warm amber and soft soft teal accents. Clean, professional, tech-magazine style. No text, no words, no letters."

curl -s -X POST "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=$GOOGLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contents": [{"parts": [{"text": "'"$IMAGE_PROMPT"'"}]}],
    "generationConfig": {
      "responseModalities": ["IMAGE"],
      "imageConfig": {"aspectRatio": "16:9"}
    }
  }' > /tmp/gemini_image_response.json
```

Extract the base64 image from `candidates[0].content.parts[].inlineData.data`, decode to PNG.

### Phase 7: Upload to R2

```bash
SLUG="the-post-slug"
TIMESTAMP=$(date +%s)
R2_KEY="uploads/${SLUG}-${TIMESTAMP}.png"

cd /root/repo-workspace/prompt-aura
npx wrangler r2 object put "your-assets-bucket/${R2_KEY}" \
  --file=/tmp/feature-image.png \
  --content-type="image/png" \
  --remote 2>&1
```

Public URL: `https://your-images.example.com/${R2_KEY}`

### Phase 8: Insert Post into D1

Write a SQL file (escaping single quotes by doubling them) and execute:

```bash
npx wrangler d1 execute promptaura-db --remote --file=/tmp/insert_post.sql 2>&1
```

SQL template (use `''` for literal single quotes inside content):

```sql
INSERT INTO blog_post (id, slug, title, content, excerpt, status, featureImage, tags, categories, writerId, createdAt, updatedAt, publishedAt)
VALUES (
  '<UUID>',
  '<slug>',
  '<TITLE>',
  '<MARKDOWN CONTENT>',
  '<EXCERPT>',
  'published',           -- or 'draft' if controversial
  'https://your-images.example.com/uploads/<R2_KEY>',
  '["tag1","tag2"]',
  '["Category"]',
  'YOUR_WRITER_ID',
  <EPOCH_MS>,
  <EPOCH_MS>,
  <EPOCH_MS>
);
```

### Phase 8.5: Verify the Publish (MANDATORY)

A run is NOT successful until verified. After the D1 insert:

```bash
# 1. Row exists with a sane timestamp (must decode to TODAY)
npx wrangler d1 execute promptaura-db --remote --command "SELECT slug, publishedAt FROM blog_post WHERE slug='<SLUG>'"
date -d @$((<PUBLISHEDAT>/1000))   # must print today's date — if not, fix the row before reporting

# 2. Live page renders
curl -s -o /dev/null -w "%{http_code}" "https://your-blog.example.com/blog/<SLUG>"   # expect 200
```

If either check fails, fix the row (UPDATE the timestamps) or diagnose before reporting success. Never report "published" from the insert alone — the Aug 18 run "succeeded" while its post sat invisible with a Jan-2025 timestamp.

### Phase 9: Controversy Check — Draft vs Published

Before publishing, assess the content:

**PUBLISH as `published`** (default — 95% of posts):
- Product launches, research findings, tool comparisons, strategy guides, opinion pieces

**HOLD as `draft`** (flag for review — rare):
- Claims about specific companies or individuals that could be defamatory
- Politically sensitive topics (geopolitics, elections, regulation debates)
- Unverified claims presented as fact
- Content that takes a strong partisan stance on divisive social issues
- Anything that could damage PPAI's or the user's professional reputation if widely shared

If publishing as draft, post a Discord notification to the user explaining WHY it needs review.

### Phase 10: Discord Delivery

Post a report to the user's Discord channel `YOUR_CHANNEL_ID` (openclaw-research).

**Find or create today's thread** — reuse existing thread for the same date:

```bash
THREAD_NAME="📝 Blog Published — $(date -u -d '+8 hours' '+%b %-d, %Y')"
CHANNEL_ID="YOUR_CHANNEL_ID"

# First, check active threads for today's thread
THREAD_ID=$(curl -s -X GET "https://discord.com/api/v10/channels/${CHANNEL_ID}/threads/active" \
  -H "Authorization: Bot $DISCORD_BOT_TOKEN" \
  | jq -r --arg name "$THREAD_NAME" '.threads[] | select(.name == $name) | .id // empty')

# If not in active, check archived public threads
if [ -z "$THREAD_ID" ]; then
  THREAD_ID=$(curl -s -X GET "https://discord.com/api/v10/channels/${CHANNEL_ID}/threads/archived/public" \
    -H "Authorization: Bot $DISCORD_BOT_TOKEN" \
    | jq -r --arg name "$THREAD_NAME" '.threads[] | select(.name == $name) | .id // empty')
fi

# If still not found, create new thread
if [ -z "$THREAD_ID" ]; then
  THREAD_ID=$(curl -s -X POST "https://discord.com/api/v10/channels/${CHANNEL_ID}/threads" \
    -H "Authorization: Bot $DISCORD_BOT_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"name": "'"$THREAD_NAME"'", "auto_archive_duration": 1440, "type": 11}' \
    | jq -r '.id // empty')
fi

# Post the report to the thread
curl -s -X POST "https://discord.com/api/v10/channels/${THREAD_ID}/messages" \
  -H "Authorization: Bot $DISCORD_BOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"content": "'"$(echo -e "## Blog Published ✅\n\n**Title:** <TITLE>\n**URL:** https://your-blog.example.com/blog/<SLUG>\n**Feature Image:** <IMAGE_URL>\n**Vault Source:** <VAULT_PATH>\n**Status:** <STATUS>\n**Summary:** <SUMMARY>" | sed ':a;N;$!ba;s/\n/\\n/g' | sed 's/"/\\"/g')"'"}'
```

Post the report inside the thread with:
- Post title and URL: `https://your-blog.example.com/blog/<slug>`
- Feature image URL
- Vault source story (relative path)
- Status: Published ✅ or Draft (needs review) ⚠️
- One-line summary

## Common Pitfalls

1. **SQL single-quote escaping**: In D1 SQL, literal single quotes must be doubled (`''`). Always write content to a `.sql` file and use `--file=` instead of `--command=`.
2. **R2 local vs remote**: Always pass `--remote` when uploading to R2, otherwise it goes to local state only.
3. **Timestamps**: D1 stores timestamps as integer milliseconds. Compute them with `$(date +%s)000` in the shell — NEVER write a literal epoch number in the SQL. Failure mode seen Aug 18, 2026: the model wrote literal `1736014400000` (January 2025) instead of computing current time; the post published but was buried ~90 posts deep in the date-sorted index. Before executing the INSERT, verify the epoch is the current date: `date -d @$((TS/1000))` must print today. Also verify the image filename timestamp matches the same epoch.
4. **Vault status dedup**: Check the `status` frontmatter in vault files. `status: Published` means the story was already turned into a blog post — skip it. When you start writing a post from a story, flip `status:` to `Curated`; after the post goes live, flip to `Published` and commit the vault change.
5. **Deduplication**: Always check existing blog slugs in D1 before selecting a vault story. Some headlines may need slug variations if topics overlap.
6. **Nano Banana image prompt**: Always end with "No text, no words, no letters" to avoid garbled text in images. Use abstract/editorial style prompts, not photorealistic.
7. **Missed vault days**: If a daily run was missed, `ls -t` will show older files. Query the last 5 days of files instead of just the latest.
8. **env vars in cron**: Always run `set -a; source /root/.env; set +a` at the start — cron environments don't auto-load .env.
9. **wrangler working directory**: Always `cd /root/repo-workspace/prompt-aura` before wrangler commands so it finds `wrangler.toml`.
10. **Cron job Discord delivery**: When this skill is used in a cron job, the agent does NOT automatically run the Phase 10 bash commands — it implements Discord delivery in its own code. The cron job's prompt MUST explicitly include the terminal commands to execute (see Phase 10 for the exact bash script). Without this, the agent will create new threads on every run instead of reusing today's thread.
11. **Thread reuse is critical**: The Discord channel will accumulate duplicate "Blog Published — <date>" threads if the find-or-create logic isn't executed. Always verify the thread ID exists before posting.
12. **Cron prompt conflict**: The cron job's own prompt says "you MUST create a new thread" — that instruction is STALE and WRONG. Always follow this skill's Phase 10 find-or-create logic instead: check active threads, check archived, only create if missing. Never blindly create a new thread even if the cron prompt orders it.
13. **Failure to publish = alert, not silence**: If any phase fails or the session cannot complete the publish, the run must post a failure report to the Discord channel (channel YOUR_CHANNEL_ID, in that day's thread or a new one titled "⚠️ Blog Publisher Failed — <date>"). A truncated session publishes nothing and tells no one — that is the worst outcome. Distinguish: (a) no candidate stories → normal, report quietly; (b) pipeline error → alert with phase number and error.
14. **Model-specific failure watch**: nvidia/nemotron models have twice hallucinated data in this pipeline (Jan-2025 timestamp literal; "January 2025: ~$20M" invented dates in post body). If the job's model is a nemotron variant and you inherit its output, independently re-verify every timestamp and date claim against the vault source before trusting it.

## Verification Checklist

- [ ] Environment variables loaded
- [ ] Existing blog slugs fetched from D1
- [ ] Candidate stories read from Obsidian vault and deduplicated
- [ ] Best story selected based on blog angle quality
- [ ] Blog post written (800-1500 words)
- [ ] **Humanizer pass completed** (Phase 5.5) — full pipeline run, verification checklist passed
- [ ] Feature image generated via Nano Banana
- [ ] Image uploaded to R2 with `--remote`
- [ ] Post inserted into D1 (published or draft)
- [ ] Controversy check performed
- [ ] Discord report posted to thread
