← Back to Skills Marketplace
🔌

Seo Blog Writer

by AutomateLab · GitHub ↗ · v2.2.0 · MIT-0
cross-platform ✓ Security Clean
51
Downloads
0
Stars
0
Active Installs
1
Versions
Install in OpenClaw
/install automatelab-seo-blog-writer
Description
Turn a single long-tail query into a publish-ready blog post that ranks in search and gets quoted by AI assistants. Runs the full pipeline: classify the topi...
README (SKILL.md)

\r \r

seo-blog-writer\r

\r End-to-end pipeline for shipping a single long-tail blog post: topic -> research -> draft -> scrub -> AI-SEO audit -> publish. Designed for SEO and AI-citation extractability (FAQ blocks, BreadcrumbList + FAQPage + HowTo schema, query-phrased headings).\r \r The writing pipeline is platform-agnostic — it produces a publish-ready bundle (clean HTML, slug, meta, JSON-LD blocks, feature-image alt). The publish step is pluggable: out-of-the-box adapters for Ghost Admin API, WordPress REST, and static-site file output. Adding another CMS (Webflow, Sanity, Strapi, Contentful, Hugo, Astro) is a matter of writing a 20-line POST snippet.\r \r The skill takes one required argument: the topic. Optional flags control the publish target and state.\r \r

/seo-blog-writer \x3Ctopic>\r
/seo-blog-writer \x3Ctopic> --target ghost                     # publish via Ghost adapter\r
/seo-blog-writer \x3Ctopic> --target wordpress                 # publish via WordPress REST\r
/seo-blog-writer \x3Ctopic> --target static --out posts/       # write files into a static-site repo\r
/seo-blog-writer \x3Ctopic> --target ghost --publish           # actually publish (default: draft)\r
/seo-blog-writer \x3Ctopic> --target ghost --publish-at \x3CISO>  # schedule for future publish\r
/seo-blog-writer \x3Ctopic> --angle "\x3Cangle>"                  # narrow the angle\r
```\r
\r
Default state is **draft** — the post lands in the platform's editor for human review before going live, unless `--publish` or `--publish-at` is passed. `--publish-at` accepts an ISO 8601 UTC timestamp (e.g. `2026-05-10T07:42:00Z`) and is mutually exclusive with `--publish`.\r
\r
Default `--target` is `static` — writes a self-contained HTML file + a `metadata.json` next to it so you can wire any platform yourself.\r
\r
---\r
\r
## Before you start — preflight\r
\r
The platform-agnostic checks:\r
\r
```bash\r
# 1. Python available (rasterizer, scrubber, schema builder)\r
command -v python3\r
\r
# 2. Working directory writable\r
mkdir -p tmp/blog-drafts && touch tmp/blog-drafts/.touch && rm tmp/blog-drafts/.touch\r
```\r
\r
**3. (Optional) ai-seo MCP — check before continuing**\r
\r
Check whether the current agent session has access to a tool named `audit_page` from the ai-seo-mcp server (`@automatelab/ai-seo-mcp`). That MCP provides a programmatic citation-worthiness and schema score that Step 5 uses automatically when available.\r
\r
- **If the MCP is connected:** nothing to do — Step 5 will call `audit_page` automatically.\r
- **If the MCP is not connected:** ask the user:\r
\r
  > "The **ai-seo MCP** (`@automatelab/ai-seo-mcp`) is not connected. Step 5 can run a programmatic citation-worthiness and schema score on your draft in addition to the manual audit. To install it:\r
  > ```\r
  > npx -y @automatelab/ai-seo-mcp\r
  > ```\r
  > then register it in your MCP config. See the [ai-seo-mcp README](https://github.com/AutomateLab-tech/ai-seo-mcp) for one-line configs for Claude Code, Cursor, and Cline. Type **skip** to continue with the manual-only audit."\r
\r
  Wait for the user's response before continuing to Step 0. Any response other than a config/install action counts as skip — proceed without the MCP.\r
\r
Platform-specific credential checks live in the per-adapter sections at the end of this skill. The writing pipeline (Steps 0-7) runs without any platform credentials — credentials are only needed at Step 8.\r
\r
---\r
\r
## Step 0 — Parse and classify the topic\r
\r
The topic is the one thing the skill cannot invent. It must arrive as an argument.\r
\r
| Shape | Example | Treatment |\r
|---|---|---|\r
| **Long-tail how-to** | `"how to fix n8n HTTP Request 401 error"` | Ideal. Format = troubleshooting (template 1). |\r
| **Integration walk-through** | `"how to connect Airtable to Slack with Zapier"` | Format = integration (template 2). |\r
| **Workflow tutorial** | `"automate invoice processing with Make"` | Format = workflow tutorial (template 3). |\r
| **Comparison** | `"Zapier vs Make vs n8n"` | Format = comparison (template 4). |\r
| **Definition / explainer** | `"what is an AI agent"` | Format = explainer (template 5). |\r
| **Use case / outcome** | `"build a daily Slack digest from RSS with n8n"` | Format = use-case (template 6). |\r
| **Listicle / roundup** | `"12 best n8n templates for marketing teams"` | Format = listicle (template 7). |\r
| **Migration guide** | `"migrate from Zapier to n8n"` | Format = migration (template 8). |\r
| **Release recap** | `"what's new in n8n 1.80"` | Format = release-recap (template 9). |\r
| **Too vague** | `"AI"`, `"automation"` | **Stop.** Ask the user to narrow it. Suggest 2-3 candidate long-tail variants. |\r
\r
If `--angle` was passed, append it to the topic. The classification picks the structural template used in Step 3.\r
\r
---\r
\r
## Step 1 — Research\r
\r
The piece must be specific. Real version numbers, real error messages, real screenshots — not generic "best practices."\r
\r
### 1a. Identify the search intent\r
\r
What does someone typing this query want? One sentence — the implicit desire behind the words.\r
\r
- `"how to fix n8n HTTP 401"` -> wants the exact change to make in the UI to stop the error\r
- `"Zapier vs Make"` -> wants a quick decision, then a longer breakdown\r
- `"what is an AI agent"` -> wants a one-paragraph explanation, then how it differs from a workflow\r
\r
If you can't write one sentence describing the intent, the topic is too vague — go back to Step 0.\r
\r
### 1b. Seed search and SERP teardown\r
\r
```\r
WebSearch("\x3Ctopic>")\r
WebSearch("\x3Ctopic> \x3Ccurrent-year>")  # force a fresh lens\r
```\r
\r
Extract three structured signals from the page-1 results:\r
\r
1. **Word count distribution** — eyeball the top 5 results' length. Target 1.1–1.3x the median, not the longest. If the median is 600 words, don't write 1500 — that's padding.\r
2. **People Also Ask boxes** — Google surfaces 4-8 PAA questions for most queries. These are free FAQ content. Capture verbatim into the FAQ-variant list.\r
3. **Currently-winning featured snippet** — if there is one, note its format (paragraph, list, table). Write the lead paragraph in that exact shape; that's how you challenge for the snippet.\r
\r
Goal: write something **more specific or more current** than the existing top results, not a paraphrase.\r
\r
### 1c. Deep fetch\r
\r
Pick **2-4 URLs** from the SERP. Prioritize:\r
\r
- **Vendor docs** — primary sources for the tool being discussed.\r
- **GitHub issues / changelogs** — for "fix X error" topics, the actual issue thread is gold.\r
- **Reddit / community forums** — for confirming a workaround actually works in the wild.\r
- **Existing top-ranked posts** — to see the bar you're clearing.\r
\r
```\r
WebFetch(url, "Return the full article body as clean prose. Include code snippets,\r
error messages, and screenshot references verbatim. Do NOT summarize.")\r
```\r
\r
Skip SEO-farm rewrites and listicles with no specifics.\r
\r
### 1d. Five-question gate before drafting\r
\r
Before writing, you must be able to answer all five.\r
\r
1. **What is the exact query intent?** (one sentence from 1a)\r
2. **What is the direct answer?** (one to two sentences — the lead paragraph in compressed form)\r
3. **What's the canonical primary source?** (vendor doc, GitHub issue, official changelog — at least one URL)\r
4. **What's the gotcha most existing posts miss?** (the specific detail that makes this post worth writing). **Hard rule:** if the honest answer is "nothing, I'm summarizing the docs," **abort and tell the user**. A doc paraphrase will rank below the actual docs.\r
5. **What 3-6 follow-on questions belong in the FAQ?** (long-tail variations of the main query, ideally lifted from the PAA boxes captured in 1b)\r
\r
If any answer is `?`, keep researching or ask the user for a specific source.\r
\r
### 1e. Save research artifacts\r
\r
```bash\r
mkdir -p tmp/blog-drafts\r
# \x3Cslug> = kebab-case of the topic, e.g. n8n-http-401-fix\r
```\r
\r
Files (gitignored):\r
- `tmp/blog-drafts/\x3Cslug>.research.md` — 5-question answers, source list, key quotes\r
- `tmp/blog-drafts/\x3Cslug>.interlinks.json` — written in Step 1f (outbound interlink targets)\r
- `tmp/blog-drafts/\x3Cslug>.draft.html` — written in Step 3\r
- `tmp/blog-drafts/\x3Cslug>.schema.html` — written in Step 7b (JSON-LD `\x3Cscript>` blocks)\r
- `tmp/blog-drafts/\x3Cslug>.metadata.json` — written in Step 7f (title, slug, tags, meta, etc.)\r
- `tmp/blog-drafts/\x3Cslug>.refresh.json` — written in Step 7h (versions, prices, years cited; for future refresh runs)\r
\r
### 1f. Outbound interlinks (recommended; required for >800-word posts)\r
\r
Pick **2-3 prior posts** on the same site whose topic genuinely overlaps with this one. Bake the links into the draft in Step 3 on topical noun phrases (not "see this post"). Internal links don't carry `nofollow`; outbound links to other domains do (see Step 3 link policy).\r
\r
Where the candidate list comes from depends on the platform:\r
\r
- **Ghost** — `GET /ghost/api/admin/posts/?limit=all&filter=status:published&fields=id,slug,title,published_at,custom_excerpt&order=published_at%20desc` (same `GHOST_ADMIN_KEY` Step 8 uses).\r
- **WordPress** — `GET /wp-json/wp/v2/posts?per_page=100&_fields=id,slug,title,date,excerpt&orderby=date&order=desc` (same `WP_APP_PASSWORD` Step 8 uses).\r
- **Static-site** — read the SSG's content directory directly (`ls content/posts/*.md`) or maintain a hand-curated `posts-inventory.json` in the repo.\r
\r
Save the chosen targets so Step 3 can splice them in and Step 7g can verify they survived the audit:\r
\r
```bash\r
cat > tmp/blog-drafts/\x3Cslug>.interlinks.json \x3C\x3C'EOF'\r
{\r
  "outbound": [\r
    {"slug": "\x3Cprior-slug-1>", "url": "https://\x3Cyour-host>/\x3Cprior-slug-1>/", "anchor_phrase": "\x3Cnoun phrase>"},\r
    {"slug": "\x3Cprior-slug-2>", "url": "https://\x3Cyour-host>/\x3Cprior-slug-2>/", "anchor_phrase": "\x3Cnoun phrase>"}\r
  ]\r
}\r
EOF\r
```\r
\r
Step 7g verifies that every `outbound[].url` appears at least once as an `href` in the final draft. If you decided mid-draft to drop a link, edit the file before re-running 7g. Posts under 800 words can skip this step; long posts ship with outbound links or they look orphaned to both the reader and the site graph.\r
\r
> **Note on inbound links.** Editing prior posts after publish to add a forward link back to the new one (inbound splicing) is a separate concern that depends on having write access to historical posts and a state file to keep the operation idempotent. This skill does not handle it — too platform-specific to generalize. If you want it, run it as a cron against your platform's API after publish.\r
\r
---\r
\r
## Step 2 — Pick the format and length band\r
\r
Each query type maps to a structural template:\r
\r
| Format | Length band |\r
|---|---|\r
| `how-to-fix` (troubleshooting) | 600-1200 |\r
| `how-to-connect` (integration) | 1000-1500 |\r
| `how-to-automate` (workflow) | 1000-1500 |\r
| `x-vs-y` (comparison) | 1200-1500 |\r
| `what-is` (explainer) | 600-1200 |\r
| `use-case` (outcome) | 1000-1500 |\r
| `listicle` (roundup) | 1500-2500 |\r
| `migration` | 1200-1800 |\r
| `release-recap` | 800-1400 |\r
\r
**Hard length range: 600-1500 words for most formats.** Word count = prose inside `\x3Cp>` tags + heading text. Excludes code blocks, table cells, figcaptions.\r
\r
Use the SERP word-count signal from Step 1b to pick a target inside the band (1.1–1.3x the SERP median). Under the floor means the answer is genuinely too thin — add an FAQ expansion, a "common errors" section, or a "how to verify" section. Over the ceiling means the post is sprawling — cut the weakest section. **Never pad to hit a floor.** Google rewards directness; AI Overviews preferentially extract from concise answers.\r
\r
---\r
\r
## Step 3 — Draft the post\r
\r
Write directly in HTML. Allowed tags:\r
\r
`\x3Cp>`, `\x3Ch2>`, `\x3Ch3>`, `\x3Ca>`, `\x3Cstrong>`, `\x3Cem>`, `\x3Ccode>`, `\x3Cpre>`, `\x3Cblockquote>`, `\x3Cul>`, `\x3Col>`, `\x3Cli>`, `\x3Ctable>`, `\x3Cthead>`, `\x3Ctbody>`, `\x3Ctr>`, `\x3Cth>`, `\x3Ctd>`, `\x3Cfigure>`, `\x3Cfigcaption>`, `\x3Cimg>`.\r
\r
No inline styles. No `\x3Cdiv>`, no `\x3Cspan>`, no `\x3Cbr>`. No H1 (most platforms emit the post title as H1; emitting your own creates a duplicate).\r
\r
### Link policy — internal vs. outbound, follow vs. nofollow\r
\r
| Destination | `rel` attribute |\r
|---|---|\r
| Your own blog (other posts on the same host) | none — internal, follow |\r
| Anything else (vendor docs, GitHub, news, social, all third-party) | `rel="nofollow noopener"` |\r
\r
Do not use `target="_blank"` — most blog themes handle outbound link UX themselves. Set `CANONICAL_HOST=blog.example.com` in the shell before running the audit in Step 5 so the validator knows which links are internal.\r
\r
### Voice checks while drafting\r
\r
- **Open with a TL;DR block.** First child of the body is `\x3Cp>\x3Cstrong>TL;DR:\x3C/strong> ...\x3C/p>` — a single sentence, 8-40 words, that answers the query directly with specific nouns (tool name, version, error code, command). LLM citation hook. Asserted in Step 7g.\r
- **Lead paragraph follows the TL;DR** with one or two sentences of context (when this hits, who it bites, why other guides miss the cause). It is not a re-statement of the answer.\r
- **H2 as a question or operational label.** Every `\x3Ch2>` either ends with `?` (e.g. `## How do you fix the "ECONNREFUSED" error in n8n?`) **or** is one of the allowlist: `Install`, `Prerequisites`, `Links`, `TL;DR`, `FAQ`, `Frequently asked questions`, `Summary`, `References`, `Further reading`, `Sources`, `Bottom line`. `\x3Ch3>` follows the same convention. Question-shaped H2s are how Google AI Overviews and Perplexity slice the page into citable chunks. Asserted in Step 7g.\r
- **Specific over general.** Real version numbers, real error messages, real prices. No "modern", "powerful", "robust", "seamless."\r
- **Impersonal voice.** "Here's the fix." Not "we found that" and not "I tried this."\r
- **Forensic linking.** Every external claim links on the noun phrase that names the source. Every external link has `rel="nofollow noopener"`.\r
- **Bullet discipline.** No `\x3Cul>` or `\x3Col>` under 3 items — convert to prose. No list over 9 items without a sub-grouping (split into 2 lists under separate H3s, or fold into a `\x3Ctable>`). Every `\x3Cli>` carries a data point, recommendation, or argument; each ends with a period; parallel grammar across items. Asserted in Step 7g.\r
- **Structured-spec labels for diagnostic posts.** Troubleshooting roundups, "N reasons X is broken", and cause/effect listicles repeat a labeled triple inside every item — the default is `**Symptom:**` / `**Diagnostic:**` / `**Fix:**` (one paragraph each). The bold-keyword-colon form is allowed here and only here. For migration posts use `**Before:**` / `**After:**` / `**Migration step:**`; for comparison posts use `**When to pick:**` / `**Avoid if:**` / `**Cost:**`. This is what gets AI assistants to extract per-item structured citations instead of mashing the whole list into one quote.\r
- **Recap checklist before the FAQ for enumerative posts.** Posts with **three or more enumerated items** close with an `\x3Col>` of one-sentence imperative steps under a question-shaped H2 (e.g. `\x3Ch2>How do you test all seven blockers in 20 minutes?\x3C/h2>`). One step per body item, no sub-bullets. Skip for posts under 800 words or fewer than three items. The recap is what gets quoted as the AI-answer "summary" — without it the model has to invent one.\r
- **Currency where it matters.** Any version number, year, or price in a load-bearing claim either is current (cross-check against vendor docs in Step 5) or carries `as of \x3CYYYY-MM>` next to it so a reader knows the time-context. Step 7g flags any year > 1 year stale without an explicit `as of` qualifier.\r
- **End with a `\x3Ch2>FAQ\x3C/h2>` block** — 3-6 H3 questions, each with a 1-3 sentence answer.\r
- **Self-check:** *Does the TL;DR stand alone as a quotable answer? Does the lead paragraph add context the TL;DR doesn't have? If either fails, rewrite.*\r
\r
Save to `tmp/blog-drafts/\x3Cslug>.draft.html`.\r
\r
---\r
\r
## Step 4 — Scrub LLM tells\r
\r
Run **before** the AI-SEO audit. The audit may add vocabulary the scrub would then need to remove; do the order this way.\r
\r
### 4a. Character scrub (automatic)\r
\r
Replace common LLM-tell characters with ASCII equivalents:\r
\r
```bash\r
python3 -c "\r
import sys, pathlib\r
p = pathlib.Path(sys.argv[1])\r
t = p.read_text(encoding='utf-8')\r
# em-dash/en-dash -> hyphen\r
t = t.replace('—', '-').replace('–', '-')\r
# smart quotes -> straight quotes\r
t = t.replace('“', '\"').replace('”', '\"')\r
t = t.replace('‘', \"'\").replace('’', \"'\")\r
# ellipsis -> three dots\r
t = t.replace('…', '...')\r
# zero-width / non-breaking space -> regular space or empty\r
t = t.replace('​', '').replace(' ', ' ')\r
p.write_text(t, encoding='utf-8')\r
print('scrubbed', sys.argv[1])\r
" tmp/blog-drafts/\x3Cslug>.draft.html\r
```\r
\r
### 4b. Prose-level tells (manual)\r
\r
Search the draft for these banned phrases and rewrite:\r
\r
- "delve into", "delving"\r
- "in today's fast-paced world", "in the ever-evolving"\r
- "robust", "seamless", "powerful", "cutting-edge"\r
- "harness the power of"\r
- "it's worth noting that", "it's important to note"\r
- "navigate the landscape", "navigating the complexities"\r
- "unlock the potential of", "unleash"\r
- "game-changer", "revolutionize"\r
- "leverage" (as a verb)\r
\r
Rewrite every hit — do not just delete; the surrounding sentence is usually also lazy.\r
\r
---\r
\r
## Step 5 — AI-SEO audit\r
\r
### Programmatic pass (if ai-seo-mcp is connected)\r
\r
If the ai-seo-mcp server is connected, call `audit_page` on the draft before running the manual passes:\r
\r
```\r
audit_page(url_or_path="tmp/blog-drafts/\x3Cslug>.draft.html")\r
```\r
\r
Feed the score and any flagged issues into the manual passes below as additional signal. The MCP output is advisory — the six manual passes are still required gates.\r
\r
### Manual passes\r
\r
Run the audit against the draft, checking each pass:\r
\r
1. **Structure pass** — does the lead answer the query in the first paragraph; do H2s match query phrasing; is each section self-contained.\r
2. **Authority pass** — at least one cited primary source (vendor doc / GitHub issue / changelog) on a relevant noun phrase.\r
3. **Freshness pass** — current year referenced where it makes sense; version numbers are current. **Currency check, mandatory:** any version number cited must still be the current (or one of the still-supported) versions per vendor docs. A 6-month-old "introduced in CrewAI 0.114" may now read as historical context, not present-tense scope. If the version has rolled forward, either update the framing or add `as of \x3CYYYY-MM>` next to the claim so the reader knows the time-context. Vendors ship fast; stale qualifiers tank citation quality.\r
4. **Schema readiness** — most platforms emit Article + Person + Organization schema automatically. Step 7b adds FAQPage + BreadcrumbList (always) and HowTo (procedural posts only). Confirm the FAQ block has H3 question + paragraph answer pairs the 7b extractor can parse.\r
5. **Long-tail coverage** — does the FAQ block capture 3-6 long-tail variants of the main query.\r
6. **Platform-fact pass** — any claim about a specific shell, OS, language runtime, or tool is a verifiable fact, not a vibe. Verify the load-bearing ones against vendor docs before publish.\r
\r
Apply recommendations **in place** in the draft, then re-run Step 4a (the audit may have re-introduced smart quotes).\r
\r
### Non-negotiable invariants\r
\r
- **Body is within the format's length band** (Step 2). Count via the snippet below.\r
- **TL;DR is the first `\x3Cp>` of the body**, opens with `\x3Cstrong>TL;DR:\x3C/strong>`, 8-40 words, single sentence.\r
- **Lead paragraph (second `\x3Cp>`) answers the query** in 1-2 sentences.\r
- **At least one primary-source link** with `rel="nofollow noopener"`.\r
- **FAQ block at the end** with 3-6 H3/p pairs.\r
- **Every external `\x3Ca>` carries `rel="nofollow noopener"`.**\r
- **Zero U+2014, U+201C, U+201D, U+2018, U+2019, U+2026, U+00A0, U+200B.**\r
\r
```bash\r
# Word count (excludes code blocks, table cells, figcaptions)\r
python3 -c "\r
import sys, re, pathlib\r
html = pathlib.Path(sys.argv[1]).read_text(encoding='utf-8')\r
no_code = re.sub(r'\x3Cpre\b[^>]*>.*?\x3C/pre>', ' ', html, flags=re.S|re.I)\r
no_table = re.sub(r'\x3Ctable\b[^>]*>.*?\x3C/table>', ' ', no_code, flags=re.S|re.I)\r
no_fig = re.sub(r'\x3Cfigure\b[^>]*>.*?\x3C/figure>', ' ', no_table, flags=re.S|re.I)\r
text = re.sub(r'\x3C[^>]+>', ' ', no_fig)\r
words = re.findall(r\"[A-Za-z0-9][A-Za-z0-9'-]*\", text)\r
print(f'{len(words)} words')\r
" tmp/blog-drafts/\x3Cslug>.draft.html\r
```\r
\r
```bash\r
# nofollow coverage on external links — expected: 0 violations.\r
# Set CANONICAL_HOST to your blog's hostname (e.g. blog.example.com).\r
python3 -c "\r
import re, sys, pathlib, os\r
from urllib.parse import urlparse\r
html = pathlib.Path(sys.argv[1]).read_text(encoding='utf-8')\r
host = os.environ.get('CANONICAL_HOST', '')\r
internal = {host, f'www.{host}' if host else ''}\r
internal = {h for h in internal if h}\r
violations = []\r
for m in re.finditer(r'\x3Ca\b([^>]*)>', html, flags=re.I):\r
    attrs = m.group(1)\r
    href = re.search(r'href=\"([^\"]+)\"', attrs, flags=re.I)\r
    if not href: continue\r
    h = urlparse(href.group(1)).hostname or ''\r
    if h and h not in internal:\r
        rel = re.search(r'rel=\"([^\"]+)\"', attrs, flags=re.I)\r
        rel_val = (rel.group(1) if rel else '').lower()\r
        if 'nofollow' not in rel_val:\r
            violations.append(href.group(1))\r
for v in violations: print('MISSING nofollow:', v)\r
print(f'{len(violations)} violation(s)')\r
" tmp/blog-drafts/\x3Cslug>.draft.html\r
```\r
\r
---\r
\r
## Step 6 — Illustrate the post (optional)\r
\r
Figures are not required for short posts, but **mandatory for posts >=800 words**. The rule: `figures >= max(1, words // 500)` whenever body word count >=800. An 800-word post -> 1-2 figures. A 1200-word post -> 2-3. A 1500-word post -> 3. Step 7g asserts this. Past failure mode this rule is fixing: long troubleshooting posts (1000+ words) shipped with zero figures because the agent declared the topic "too definitional" — the assert refuses those bundles.\r
\r
For figure generation (SVG flow diagrams, comparison charts, taxonomy diagrams, OG feature cards) see the companion `blog-figure-svg` skill — it generates accessible SVGs with consistent styling and rasterizes them for upload. The skill is CMS-agnostic; it produces PNG files that any adapter in Step 8 can upload.\r
\r
For screenshots, capture from the live tool (Playwright, real session, etc.), crop to the relevant region, redact tokens or personal data. Save as `tmp/blog-drafts/\x3Cslug>-\x3CN>-\x3Cshort-name>.png`.\r
\r
### Splice figure tags into the draft\r
\r
```html\r
\x3Cfigure>\r
  \x3Cimg src="\x3Cimage-url-or-path>" alt="\x3Cfull description with all numbers and labels>" loading="lazy">\r
  \x3Cfigcaption>One sentence restating the takeaway in plain English (15-30 words).\x3C/figcaption>\r
\x3C/figure>\r
```\r
\r
**Caption rules:**\r
- Required on every figure. Step 7g asserts this.\r
- 15-30 words, restating the takeaway (not "Figure showing X" — say what the reader should conclude).\r
- Allowed tags inside `\x3Cfigcaption>`: `\x3Ca>` (with `rel="nofollow noopener"` for external), `\x3Cem>`.\r
\r
The `\x3Cimg src>` value depends on the publish target:\r
- **Ghost / WordPress**: upload first (per-adapter snippet in Step 8), then splice the returned CDN URL.\r
- **Static-site**: copy the PNG into the site's image directory and use a relative path.\r
\r
---\r
\r
## Step 7 — Build the publish bundle\r
\r
The bundle is three files that every adapter consumes:\r
\r
| File | Contents |\r
|---|---|\r
| `\x3Cslug>.draft.html` | Body HTML (already produced in Step 3, scrubbed and audited). |\r
| `\x3Cslug>.schema.html` | JSON-LD `\x3Cscript>` blocks (FAQPage + BreadcrumbList + optional HowTo). |\r
| `\x3Cslug>.metadata.json` | Title, slug, tags, author, meta title/description, excerpt, feature image, status, publish-at. |\r
\r
### 7a. Headline and slug rules\r
\r
**Headline** (becomes the SEO title unless `meta_title` overrides):\r
\r
- Under **70 chars**.\r
- Match the search query closely.\r
- Lead with the verb / noun the searcher typed.\r
\r
**Slug** (URL fragment):\r
\r
- **\x3C=60 chars.**\r
- **Strip stop words** — drop `the`, `a`, `an`, `for`, `with`, `in`, `to`, `of`, `on`, `and`, `or`, `is`, `are`.\r
- **No version numbers** — `n8n-1-45-2-fix` goes stale; `n8n-http-401-fix` does not.\r
- **Match the primary keyword**, not the full headline.\r
\r
```python\r
import re\r
STOP = {'the','a','an','for','with','in','to','of','on','and','or','is','are'}\r
slug = "-".join(t for t in re.findall(r'[a-z0-9]+', topic.lower()) if t not in STOP)\r
slug = slug[:60].rstrip('-')\r
```\r
\r
### 7b. Build JSON-LD schema (FAQPage + BreadcrumbList + HowTo)\r
\r
Most platforms emit Article/BlogPosting/Person/Organization schema by default. This skill **adds three more** for AI-citation extractability:\r
\r
- **FAQPage** — mandatory. Every post has a FAQ block (Step 3 rule).\r
- **BreadcrumbList** — mandatory. `Home > \x3CPrimary Tag> > \x3CPost Title>`.\r
- **HowTo** — only for procedural formats with >=3 step-shaped H2s.\r
\r
**Critical gotcha for rich-text editors:** several CMSes (Ghost's Lexical, WordPress's block editor under some configurations) convert the source HTML into a structured format on save and silently drop `\x3Cscript>` nodes — so JSON-LD inlined in the draft body **disappears in the live page** even though it was present in the POST payload.\r
\r
The blocks must go in a platform-specific "head injection" slot:\r
\r
| Platform | Where the schema goes |\r
|---|---|\r
| Ghost | `codeinjection_head` field on the post payload |\r
| WordPress | `\x3Chead>` via a theme hook, or the Yoast / Rank Math "schema" panel |\r
| Static-site | written directly into the rendered HTML's `\x3Chead>` by your build step |\r
\r
**Never append `\x3Cscript type="application/ld+json">` to the body HTML.** Build it once via this step into `\x3Cslug>.schema.html`; the platform adapter in Step 8 reads that file and writes it into the correct field.\r
\r
```bash\r
# Args: slug, headline, format, primary-tag-name, canonical-base-url\r
python3 scripts/seo-blog-writer/build-schema.py "\x3Cslug>" "\x3Cheadline>" "\x3Cformat>" "\x3Cprimary-tag>" "\x3Ccanonical-base-url>"\r
```\r
\r
### 7c. Feature image (recommended)\r
\r
A feature image is shown at the top of the post and as the OG image in social shares. Strongly recommended for any post you intend to promote.\r
\r
Options:\r
- **Upload a custom image** — per-adapter upload snippets are in Step 8.\r
- **Generate a templated title card** — see the companion `blog-figure-svg` skill (`feature` variant) for a 1600x840 OG card with a clean headline + brand mark.\r
- **Skip it** — the post will render without a hero image; social previews fall back to the site default.\r
\r
Whatever path you pick, capture the URL (or filesystem path for static targets) plus a one-line alt-text in `metadata.json`. **Cap alt text at 191 chars** — Ghost silently truncates at varchar(191), and the limit is a reasonable upper bound for any platform.\r
\r
### 7d. Author byline\r
\r
Every post needs an author. The shape varies by platform; capture it generically in metadata:\r
\r
```json\r
"author": {"slug": "\x3Cauthor-slug>", "name": "\x3Cdisplay name>"}\r
```\r
\r
The adapter in Step 8 translates this to the platform's API shape:\r
- **Ghost** — `authors: [{"slug": "\x3Cslug>"}]`. Slug must match an existing user; otherwise Ghost silently substitutes the integration owner.\r
- **WordPress** — `author: \x3Cuser-id>` (numeric). Resolve slug -> id once and cache.\r
- **Static-site** — written into the front-matter `author:` field of the generated file.\r
\r
### 7e. Tags\r
\r
Use a flat list of tag name strings:\r
\r
```json\r
"tags": ["How To", "n8n"]\r
```\r
\r
**Pick 1-3 tags per post.** The first tag is the **primary tag** — it becomes the breadcrumb segment in 7b and is used by most themes for category labelling.\r
\r
Maintain a small canonical tag list in your project (don't let the AI invent new tags every post — duplicates dilute SEO). Common patterns: format tags (`How To`, `Tutorial`, `Comparison`, `What Is`) + topic tags (your tool/category names).\r
\r
### 7f. Build the metadata bundle\r
\r
Write the per-post fields into `tmp/blog-drafts/\x3Cslug>.params.json`, then run the\r
builder. It validates required fields and maps the status flags to every adapter.\r
\r
`params.json` shape:\r
\r
```json\r
{\r
  "title": "\x3Cheadline>",\r
  "tags": ["How To", "n8n"],\r
  "author": {"slug": "\x3Cauthor-slug>", "name": "\x3Cauthor display name>"},\r
  "meta_title": "\x3CSEO title under 60 chars>",\r
  "meta_description": "\x3CSEO description, 140-160 chars>",\r
  "custom_excerpt": "\x3Cdek shown on index page>",\r
  "feature_image": "",\r
  "feature_image_alt": "",\r
  "feature_image_caption": "",\r
  "publish": false,\r
  "publish_at": null\r
}\r
```\r
\r
First tag is the primary tag (passed to 7b for the breadcrumb). Set `publish: true`\r
for `--publish`; `publish_at` (ISO-UTC) for `--publish-at` (mutually exclusive).\r
\r
```bash\r
python3 scripts/seo-blog-writer/build-metadata.py "\x3Cslug>"\r
```\r
\r
### 7g. Pre-publish bundle validation\r
\r
Before invoking the platform adapter, all of these must hold:\r
\r
```bash\r
python3 scripts/seo-blog-writer/validate-bundle.py "\x3Cslug>"\r
```\r
\r
If any assert fires, fix and re-build before Step 8.\r
\r
### 7h. Refresh metadata snapshot\r
\r
Save a small JSON snapshot of the post's facts so a future refresh pass can identify staleness without re-reading the prose. Cheap to write now; expensive to backfill at 500 posts.\r
\r
```bash\r
python3 scripts/seo-blog-writer/refresh-meta.py "\x3Cslug>" "\x3Cformat>"\r
```\r
\r
When a topic refresh comes due (typically every 6-12 months for high-traffic posts), the refresh skill (future / your-own) diffs the snapshot's `versions_cited` against current vendor docs. Versions that have rolled forward by a major release are flagged for rewrite; everything else is left alone.\r
\r
### 7i. Glossary auto-link (optional)\r
\r
If you maintain a glossary of technical terms with definition pages on your site, pipe the draft HTML through `scripts/inject-glossary-links.py` to turn the first mention of each known term into an internal link to its definition page. Each link also carries a `data-definition` attribute that the bundled `references/decorate.js` snippet renders as a hover tooltip on the published page.\r
\r
**Skip this step if** you don't have a `glossary.json` file yet — there's no default. See [references/glossary-schema.md](references/glossary-schema.md) for the file shape and a starter example.\r
\r
```bash\r
python3 scripts/inject-glossary-links.py \\r
    tmp/blog-drafts/\x3Cslug>.draft.html \\r
    --glossary path/to/glossary.json \\r
    --base-url /glossary/ \\r
    --max-links 6 \\r
    > tmp/blog-drafts/\x3Cslug>.draft.linked.html\r
\r
mv tmp/blog-drafts/\x3Cslug>.draft.linked.html tmp/blog-drafts/\x3Cslug>.draft.html\r
```\r
\r
The injector:\r
\r
- Links **first occurrence only** per term per post (Wikipedia rule).\r
- Caps at `--max-links` (default 6), priority-sorted from the glossary.\r
- Skips headings, code/pre, tables, blockquotes, asides, existing links, and the TL;DR paragraph.\r
- Rejects matches embedded in identifier-like compounds (`user-agent` won't match `agent`, `@scope/ai-seo-mcp` won't match `mcp`).\r
- Writes a `data-definition` attribute on each link for the tooltip.\r
\r
Run order: **after Step 7g validates the draft** so the validator's structural asserts run on clean HTML; **before Step 8 publishes** so the linked HTML is what ships. Glossary links count as internal navigation, not outbound — the Step 7g outbound-survival assert ignores them.\r
\r
To enable the hover tooltip on the live site, copy `skills/seo-blog-writer/references/decorate.js` into your theme bundle (or paste it inline in a `\x3Cscript>` tag in your site `\x3Chead>`) once. It's self-contained, ~1 KB, no dependencies, and skips itself on `/glossary/*` pages.\r
\r
---\r
\r
## Step 8 — Publish via the platform adapter\r
\r
Pick one adapter per run. Each adapter reads the same bundle (`\x3Cslug>.draft.html`, `\x3Cslug>.schema.html`, `\x3Cslug>.metadata.json`) and writes the post to its target platform.\r
\r
---\r
\r
### Adapter A — Ghost (Admin API)\r
\r
The Ghost adapter uses the Admin API over HTTPS. No Docker, no SSH — just authenticated POST to `/ghost/api/admin/posts/`.\r
\r
**Credentials**:\r
\r
| Env var | Source | Shape |\r
|---|---|---|\r
| `GHOST_URL` | Your Ghost site URL | `https://blog.example.com` (no trailing slash) |\r
| `GHOST_ADMIN_KEY` | Ghost admin -> Settings -> Integrations -> (your integration) -> **Admin API Key** | `\x3C24-hex>:\x3C64-hex>` combined |\r
\r
Preflight:\r
\r
```bash\r
curl -sS "$GHOST_URL/ghost/api/admin/site/" | head -c 80\r
[ -n "$GHOST_URL" ] && [ -n "$GHOST_ADMIN_KEY" ] && echo "keys present" || echo "MISSING"\r
```\r
\r
**Image upload** (call once per figure, then splice the returned URL into the draft):\r
\r
```bash\r
python3 scripts/seo-blog-writer/ghost-upload-image.py "\x3Cimage-path>"\r
```\r
\r
**Publish the post**:\r
\r
```bash\r
python3 scripts/seo-blog-writer/publish-ghost.py "\x3Cslug>"\r
```\r
\r
`?source=html` tells Ghost to convert the `html` field into Lexical. Without it, Ghost treats the field as Lexical JSON and the POST fails with a 422.\r
\r
**Python deps**: `pip install requests pyjwt`. PyJWT 2.x required.\r
\r
---\r
\r
### Adapter B — WordPress (REST API)\r
\r
Uses the WordPress REST API with **Application Password** auth (Users -> Profile -> Application Passwords). Works on any WP site with REST exposed at `/wp-json/wp/v2/`.\r
\r
**Credentials**:\r
\r
| Env var | Source | Shape |\r
|---|---|---|\r
| `WP_URL` | Your WordPress site URL | `https://blog.example.com` (no trailing slash) |\r
| `WP_USER` | The WP username the app password belongs to | `admin` |\r
| `WP_APP_PASSWORD` | Profile -> Application Passwords -> new -> "seo-blog-writer" | `xxxx xxxx xxxx xxxx xxxx xxxx` |\r
\r
Preflight:\r
\r
```bash\r
curl -sS "$WP_URL/wp-json/wp/v2/" | head -c 120\r
[ -n "$WP_URL" ] && [ -n "$WP_USER" ] && [ -n "$WP_APP_PASSWORD" ] && echo "keys present" || echo "MISSING"\r
```\r
\r
**Image upload** (returns the media id and URL):\r
\r
```bash\r
python3 scripts/seo-blog-writer/wp-upload-image.py "\x3Cimage-path>"\r
```\r
\r
**Publish the post**:\r
\r
```bash\r
python3 scripts/seo-blog-writer/publish-wordpress.py "\x3Cslug>"\r
```\r
\r
**Notes**:\r
- `featured_media` in the post payload is a media **id**, not a URL. Upload the feature image first, capture the id, then set `post["featured_media"] = \x3Cid>`.\r
- WordPress accepts `\x3Cscript>` in `content` only if the user has the `unfiltered_html` capability (admins do by default; editors may not). If your user lacks it, install a small theme snippet that reads the schema from a post meta key into `wp_head`.\r
\r
---\r
\r
### Adapter C — Static-site (file output)\r
\r
For Hugo / Astro / Eleventy / Jekyll / Next-MDX style setups where posts live as files in a git repo. The adapter writes the bundle into the target directory; your usual build + deploy takes it from there.\r
\r
**No credentials.** Just a target path.\r
\r
```bash\r
python3 scripts/seo-blog-writer/publish-static.py "\x3Cslug>" "\x3Cout-dir>"\r
```\r
\r
Your SSG's layout template needs one line to include the schema in `\x3Chead>` — e.g. for Hugo:\r
\r
```html\r
{{ if (fileExists (printf "content/posts/%s.schema.html" .File.BaseFileName)) }}\r
  {{ readFile (printf "content/posts/%s.schema.html" .File.BaseFileName) | safeHTML }}\r
{{ end }}\r
```\r
\r
For Astro / Eleventy / Next, do the equivalent (read file at build time, inject into the layout head).\r
\r
---\r
\r
### Adapter D — bring-your-own\r
\r
The bundle is a stable contract. Any platform with an "upload an image" and a "create a post" endpoint can be adapted in ~50 lines. The contract:\r
\r
- `\x3Cslug>.draft.html` — body HTML, post-scrub, post-audit.\r
- `\x3Cslug>.schema.html` — JSON-LD `\x3Cscript>` blocks to inject in `\x3Chead>`.\r
- `\x3Cslug>.metadata.json` — title, slug, tags (string list), author (slug + name), meta title/desc, excerpt, feature image (URL or local path), status (`draft` / `published` / `scheduled`), published_at (ISO).\r
\r
Adapter examples shipped above (Ghost, WordPress, static) cover ~90% of small-publisher use cases. Webflow CMS, Sanity, Strapi, and Contentful each take a similar shape: POST to the platform's content endpoint with their auth header, body field, and metadata fields.\r
\r
---\r
\r
### Step 8b. Report back to the user\r
\r
Whatever adapter ran, the final report includes:\r
\r
- Draft URL or live URL (`\x3Cbase-url>/\x3Cslug>/` if published; admin edit URL if draft).\r
- Platform admin / repo edit URL.\r
- Word count, tag list, author slug.\r
- Confirmation: scrub passed, AI-SEO audit applied, FAQ block present, JSON-LD injected.\r
- Figure URLs and captions.\r
\r
---\r
\r
## Step 9 — Verify live post (only if `--publish`)\r
\r
```bash\r
# Post is reachable\r
curl -sSI "\x3Cbase-url>/\x3Cslug>/" | head -5\r
\r
# Post in RSS\r
curl -sS "\x3Cbase-url>/rss/" | grep -o "\x3Ctitle>[^\x3C]*\x3C/title>" | head -5\r
\r
# Post in sitemap (path varies by platform — Ghost: /sitemap-posts.xml; WP: /sitemap.xml; SSG: as configured)\r
curl -sS "\x3Cbase-url>/sitemap-posts.xml" | grep "\x3Cslug>"\r
\r
# OG + full schema set rendered\r
curl -sS "\x3Cbase-url>/\x3Cslug>/" | grep -o 'property="og:[^"]*"' | sort -u\r
curl -sS "\x3Cbase-url>/\x3Cslug>/" | grep -oE '"@type":\s*"[^"]+"' | sort -u\r
```\r
\r
**Expected:** `HTTP/2 200`, slug in RSS and sitemap, `og:title`/`og:description` present. The `"@type"` set must include **`Article`** (or `BlogPosting`), **`FAQPage`**, and **`BreadcrumbList`**; procedural how-to posts must also include **`HowTo`**. Missing FAQPage/BreadcrumbList means the schema slot wasn't wired correctly — check the platform-specific head-injection field.\r
\r
---\r
\r
## What this skill does NOT do\r
\r
- **Does not commit to git.** Adapters write to CMS APIs or to your static-site directory; the latter you commit yourself.\r
- **Does not schedule posts by default.** Pass `--publish-at \x3CISO-UTC>` to schedule. Without it the post lands as draft (default) or live (`--publish`).\r
- **Does not handle member-only posts, newsletters, or email sends.** Each platform's newsletter flow is manual via its admin UI.\r
- **Does not generate figures.** Use the companion `blog-figure-svg` skill for SVG charts, taxonomies, and flow diagrams.\r
- **Does not research topics from scratch.** Use the companion `blog-topic-research` skill to validate a topic has real demand signals before drafting.\r
\r
---\r
\r
## Failure modes\r
\r
| Symptom | Adapter | Cause | Fix |\r
|---|---|---|---|\r
| `401 Unauthorized` | Ghost / WordPress | Key expired / wrong key / wrong app-password | Regenerate the integration / app password |\r
| Ghost `422 Validation failed: Value in [posts.html] cannot be blank` | Ghost | Missing `?source=html` | Add the query param |\r
| Ghost `422` with `feature_image_alt` in message | Ghost | Alt text >191 chars | Trim to \x3C=191; Step 7g asserts this |\r
| `404` on slug after publish | any | Post saved as draft (default) | Drafts only reachable via admin editor URL |\r
| Body shows as one HTML blob | Ghost | Ghost fell back to plain-text mode | Re-post with `?source=html` |\r
| Smart quotes reappear in rendered post | Ghost | Ghost typographer auto-conversion | Settings -> Publication: turn off "Use typographer's quotes" |\r
| Wrong slug | any | Platform auto-slugged from title | PUT/PATCH the post with the corrected slug |\r
| Ghost `409 Conflict` on PUT | Ghost | Stale `updated_at` | Re-GET to refresh, retry |\r
| Author silently substituted | Ghost / WordPress | Author slug doesn't exist / user lacks `publish_posts` | Create the user; PUT correction with correct slug or user id |\r
| Live page missing FAQPage / HowTo `@type` (Step 9) | Ghost | JSON-LD was inlined in the body and stripped by Lexical conversion | PUT with `codeinjection_head` set to `\x3Cslug>.schema.html`; echo current `updated_at` to avoid 409 |\r
| WordPress strips `\x3Cscript type="application/ld+json">` from body | WordPress | User lacks `unfiltered_html` | Move schema injection to a theme hook reading a post meta key |\r
\r
---\r
\r
## Companion skills\r
\r
- **`blog-topic-research`** — validate a long-tail topic has real demand signals (PAA, Reddit threads, GitHub issues) before drafting. Run this *before* this skill.\r
- **`blog-figure-svg`** — generate accessible SVG figures (flow diagrams, comparison charts, taxonomy diagrams) with consistent styling. Run this *during Step 6* if the post needs illustrations.\r
\r
Together, the three form a complete long-tail SEO publishing pipeline: research the topic, write the post, illustrate it, publish.\r
\r
---\r
\r
## Maintenance scripts\r
\r
The per-post scrub in Step 4a covers the common LLM-tell characters and the per-post audit in Step 7g enforces the structural rules. For corpus-wide drift — characters or banlist phrases that crept back in across many posts — there's a separate audit script in the repo:\r
\r
```bash\r
# Sweep your published-content directory for non-ASCII chars + prose banlist\r
python3 scripts/audit-corpus.py path/to/your/content/\r
\r
# Examples (per platform):\r
python3 scripts/audit-corpus.py tmp/blog-drafts/                  # current drafts\r
python3 scripts/audit-corpus.py content/posts/                    # Hugo / Astro / 11ty\r
python3 scripts/audit-corpus.py site/source/_posts/               # Jekyll\r
\r
# Add domain-specific terms you want flagged (comma-separated):\r
python3 scripts/audit-corpus.py content/posts/ --extra "synergy,best-in-class"\r
\r
# CI mode: exit 1 on any hit, pipe to your notifier or fail the build\r
python3 scripts/audit-corpus.py content/posts/ >/dev/null || echo "drift detected"\r
```\r
\r
Default scan covers `*.html` and `*.md`. The script exits `0` clean / `1` on hits / `2` on bad invocation, so it composes with CI. Run it weekly (or as a pre-deploy step) — much cheaper than re-reading every post by hand.\r
\r
Don't point it at the publishing-skills repo itself or at the seo-blog-writer SKILL.md: both contain the banlist literals as data and will self-flag. Target your *content* directory, not your *tooling* directory.\r
Usage Guidance
Install this only for agents you trust to draft and create Ghost posts. Configure a least-privilege Ghost integration if possible, keep GHOST_ADMIN_KEY out of git, review generated drafts before using --publish or --publish-at, and periodically clean tmp/blog-drafts if the drafts may contain private material.
Capability Tags
requires-sensitive-credentials
Capability Assessment
Purpose & Capability
The skill clearly describes an end-to-end blog workflow: research, draft, scrub, SEO audit, and publish or schedule posts to a Ghost CMS site via the Admin API.
Instruction Scope
It can perform external publishing and scheduling, but the artifact states that draft is the default and live publication requires --publish or --publish-at.
Install Mechanism
The metadata only declares a python3 binary requirement; no hidden installer, package bootstrap, or automatic persistence mechanism is shown.
Credentials
It requires GHOST_URL and GHOST_ADMIN_KEY and uses network access to Ghost and research sources; that is proportionate for managing Ghost posts, though users should treat the admin key as sensitive.
Persistence & Privilege
Local writes are limited to tmp/blog-drafts artifacts, and the skill explicitly says it does not commit to git or schedule by default.
How to Use
  1. Make sure OpenClaw is installed (local or Docker)
  2. Run the install command in chat: /install automatelab-seo-blog-writer
  3. After installation, invoke the skill by name or use /automatelab-seo-blog-writer
  4. Provide required inputs per the skill's parameter spec and get structured output
Version History
v2.2.0
Initial publish (slug automatelab-seo-blog-writer; seo-blog-writer taken by unrelated skill)
Metadata
Slug automatelab-seo-blog-writer
Version 2.2.0
License MIT-0
All-time Installs 0
Active Installs 0
Total Versions 1
Frequently Asked Questions

What is Seo Blog Writer?

Turn a single long-tail query into a publish-ready blog post that ranks in search and gets quoted by AI assistants. Runs the full pipeline: classify the topi... It is an AI Agent Skill for Claude Code / OpenClaw, with 51 downloads so far.

How do I install Seo Blog Writer?

Run "/install automatelab-seo-blog-writer" in the OpenClaw or Claude Code chat to install it in one step — no extra setup required.

Is Seo Blog Writer free?

Yes, Seo Blog Writer is completely free, licensed under MIT-0. You can download, install and use it at no cost.

Which platforms does Seo Blog Writer support?

Seo Blog Writer is cross-platform and runs anywhere OpenClaw / Claude Code is available (cross-platform).

Who created Seo Blog Writer?

It is built and maintained by AutomateLab (@automatelab); the current version is v2.2.0.

💬 Comments