Multi Search Engine
一个面向 Research 场景的 Agent 技能。原始说明:Multi search engine integration with 16 engines (7 CN + 9 Global). Supports advanced search operators, time filters, site search, privacy engines, and Wolfra...
name: 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 topic, research it against real sources, draft clean HTML, scrub LLM-tell vocabulary and typography, audit for AI-SEO (TL;DR block, query-phrased H2s, FAQ section, FAQPage + BreadcrumbList + HowTo JSON-LD), then publish through a platform adapter (Ghost Admin API, WordPress REST, or static-site file output). Platform-agnostic core; swap the publish step without rewriting the writing pipeline. Built for indie hackers, founders, and content marketers who want AI to draft posts that are actually citable - not paraphrased docs, not hallucinated benchmarks. Trigger when the user says: 'write a blog post on X', 'draft an article about X', 'publish a post on X to Ghost / WordPress / the static site', or any request to ship editorial content for a long-tail query."
version: 2.2.0
emoji: "✍️"
homepage: https://github.com/AutomateLab-tech/publishing-skills
metadata:
openclaw:
requires:
bins:
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).
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.
The skill takes one required argument: the topic. Optional flags control the publish target and state.
/seo-blog-writer <topic>
/seo-blog-writer <topic> --target ghost # publish via Ghost adapter
/seo-blog-writer <topic> --target wordpress # publish via WordPress REST
/seo-blog-writer <topic> --target static --out posts/ # write files into a static-site repo
/seo-blog-writer <topic> --target ghost --publish # actually publish (default: draft)
/seo-blog-writer <topic> --target ghost --publish-at <ISO> # schedule for future publish
/seo-blog-writer <topic> --angle "<angle>" # narrow the angle
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.
Default --target is static — writes a self-contained HTML file + a metadata.json next to it so you can wire any platform yourself.
The platform-agnostic checks:
# 1. Python available (rasterizer, scrubber, schema builder)
command -v python3
# 2. Working directory writable
mkdir -p tmp/blog-drafts && touch tmp/blog-drafts/.touch && rm tmp/blog-drafts/.touch
3. (Optional) ai-seo MCP — check before continuing
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.
audit_page automatically."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:
```
npx -y @automatelab/ai-seo-mcp
```
then register it in your MCP config. See the ai-seo-mcp README for one-line configs for Claude Code, Cursor, and Cline. Type skip to continue with the manual-only audit."
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.
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.
The topic is the one thing the skill cannot invent. It must arrive as an argument.
| Shape | Example | Treatment |
|---|---|---|
| Long-tail how-to | "how to fix n8n HTTP Request 401 error" | Ideal. Format = troubleshooting (template 1). |
| Integration walk-through | "how to connect Airtable to Slack with Zapier" | Format = integration (template 2). |
| Workflow tutorial | "automate invoice processing with Make" | Format = workflow tutorial (template 3). |
| Comparison | "Zapier vs Make vs n8n" | Format = comparison (template 4). |
| Definition / explainer | "what is an AI agent" | Format = explainer (template 5). |
| Use case / outcome | "build a daily Slack digest from RSS with n8n" | Format = use-case (template 6). |
| Listicle / roundup | "12 best n8n templates for marketing teams" | Format = listicle (template 7). |
| Migration guide | "migrate from Zapier to n8n" | Format = migration (template 8). |
| Release recap | "what's new in n8n 1.80" | Format = release-recap (template 9). |
| Too vague | "AI", "automation" | Stop. Ask the user to narrow it. Suggest 2-3 candidate long-tail variants. |
If --angle was passed, append it to the topic. The classification picks the structural template used in Step 3.
The piece must be specific. Real version numbers, real error messages, real screenshots — not generic "best practices."
What does someone typing this query want? One sentence — the implicit desire behind the words.
"how to fix n8n HTTP 401" -> wants the exact change to make in the UI to stop the error"Zapier vs Make" -> wants a quick decision, then a longer breakdown"what is an AI agent" -> wants a one-paragraph explanation, then how it differs from a workflowIf you can't write one sentence describing the intent, the topic is too vague — go back to Step 0.
WebSearch("<topic>")
WebSearch("<topic> <current-year>") # force a fresh lens
Extract three structured signals from the page-1 results:
Goal: write something more specific or more current than the existing top results, not a paraphrase.
Pick 2-4 URLs from the SERP. Prioritize:
WebFetch(url, "Return the full article body as clean prose. Include code snippets,
error messages, and screenshot references verbatim. Do NOT summarize.")
Skip SEO-farm rewrites and listicles with no specifics.
Before writing, you must be able to answer all five.
If any answer is ?, keep researching or ask the user for a specific source.
mkdir -p tmp/blog-drafts
# <slug> = kebab-case of the topic, e.g. n8n-http-401-fix
Files (gitignored):
tmp/blog-drafts/<slug>.research.md — 5-question answers, source list, key quotestmp/blog-drafts/<slug>.interlinks.json — written in Step 1f (outbound interlink targets)tmp/blog-drafts/<slug>.draft.html — written in Step 3tmp/blog-drafts/<slug>.schema.html — written in Step 7b (JSON-LD <script> blocks)tmp/blog-drafts/<slug>.metadata.json — written in Step 7f (title, slug, tags, meta, etc.)tmp/blog-drafts/<slug>.refresh.json — written in Step 7h (versions, prices, years cited; for future refresh runs)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).
Where the candidate list comes from depends on the platform:
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).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).ls content/posts/*.md) or maintain a hand-curated posts-inventory.json in the repo.Save the chosen targets so Step 3 can splice them in and Step 7g can verify they survived the audit:
cat > tmp/blog-drafts/<slug>.interlinks.json <<'EOF'
{
"outbound": [
{"slug": "<prior-slug-1>", "url": "https://<your-host>/<prior-slug-1>/", "anchor_phrase": "<noun phrase>"},
{"slug": "<prior-slug-2>", "url": "https://<your-host>/<prior-slug-2>/", "anchor_phrase": "<noun phrase>"}
]
}
EOF
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.
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.
Each query type maps to a structural template:
| Format | Length band |
|---|---|
| how-to-fix (troubleshooting) | 600-1200 |
| how-to-connect (integration) | 1000-1500 |
| how-to-automate (workflow) | 1000-1500 |
| x-vs-y (comparison) | 1200-1500 |
| what-is (explainer) | 600-1200 |
| use-case (outcome) | 1000-1500 |
| listicle (roundup) | 1500-2500 |
| migration | 1200-1800 |
| release-recap | 800-1400 |
Hard length range: 600-1500 words for most formats. Word count = prose inside <p> tags + heading text. Excludes code blocks, table cells, figcaptions.
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.
Write directly in HTML. Allowed tags:
<p>, <h2>, <h3>, <a>, <strong>, <em>, <code>, <pre>, <blockquote>, <ul>, <ol>, <li>, <table>, <thead>, <tbody>, <tr>, <th>, <td>, <figure>, <figcaption>, <img>.
No inline styles. No <div>, no <span>, no <br>. No H1 (most platforms emit the post title as H1; emitting your own creates a duplicate).
| Destination | rel attribute |
|---|---|
| Your own blog (other posts on the same host) | none — internal, follow |
| Anything else (vendor docs, GitHub, news, social, all third-party) | rel="nofollow noopener" |
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.
<p><strong>TL;DR:</strong> ...</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.<h2> 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. <h3> follows the same convention. Question-shaped H2s are how Google AI Overviews and Perplexity slice the page into citable chunks. Asserted in Step 7g.rel="nofollow noopener".<ul> or <ol> 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 <table>). Every <li> carries a data point, recommendation, or argument; each ends with a period; parallel grammar across items. Asserted in Step 7g.**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.<ol> of one-sentence imperative steps under a question-shaped H2 (e.g. <h2>How do you test all seven blockers in 20 minutes?</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.as of <YYYY-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.<h2>FAQ</h2> block — 3-6 H3 questions, each with a 1-3 sentence answer.Save to tmp/blog-drafts/<slug>.draft.html.
Run before the AI-SEO audit. The audit may add vocabulary the scrub would then need to remove; do the order this way.
Replace common LLM-tell characters with ASCII equivalents:
python3 -c "
import sys, pathlib
p = pathlib.Path(sys.argv[1])
t = p.read_text(encoding='utf-8')
# em-dash/en-dash -> hyphen
t = t.replace('—', '-').replace('–', '-')
# smart quotes -> straight quotes
t = t.replace('“', '\"').replace('”', '\"')
t = t.replace('‘', \"'\").replace('’', \"'\")
# ellipsis -> three dots
t = t.replace('…', '...')
# zero-width / non-breaking space -> regular space or empty
t = t.replace('', '').replace(' ', ' ')
p.write_text(t, encoding='utf-8')
print('scrubbed', sys.argv[1])
" tmp/blog-drafts/<slug>.draft.html
Search the draft for these banned phrases and rewrite:
Rewrite every hit — do not just delete; the surrounding sentence is usually also lazy.
If the ai-seo-mcp server is connected, call audit_page on the draft before running the manual passes:
audit_page(url_or_path="tmp/blog-drafts/<slug>.draft.html")
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.
Run the audit against the draft, checking each pass:
as of <YYYY-MM> next to the claim so the reader knows the time-context. Vendors ship fast; stale qualifiers tank citation quality.Apply recommendations in place in the draft, then re-run Step 4a (the audit may have re-introduced smart quotes).
<p> of the body, opens with <strong>TL;DR:</strong>, 8-40 words, single sentence.<p>) answers the query in 1-2 sentences.rel="nofollow noopener".<a> carries rel="nofollow noopener".# Word count (excludes code blocks, table cells, figcaptions)
python3 -c "
import sys, re, pathlib
html = pathlib.Path(sys.argv[1]).read_text(encoding='utf-8')
no_code = re.sub(r'<pre\b[^>]*>.*?</pre>', ' ', html, flags=re.S|re.I)
no_table = re.sub(r'<table\b[^>]*>.*?</table>', ' ', no_code, flags=re.S|re.I)
no_fig = re.sub(r'<figure\b[^>]*>.*?</figure>', ' ', no_table, flags=re.S|re.I)
text = re.sub(r'<[^>]+>', ' ', no_fig)
words = re.findall(r\"[A-Za-z0-9][A-Za-z0-9'-]*\", text)
print(f'{len(words)} words')
" tmp/blog-drafts/<slug>.draft.html
# nofollow coverage on external links — expected: 0 violations.
# Set CANONICAL_HOST to your blog's hostname (e.g. blog.example.com).
python3 -c "
import re, sys, pathlib, os
from urllib.parse import urlparse
html = pathlib.Path(sys.argv[1]).read_text(encoding='utf-8')
host = os.environ.get('CANONICAL_HOST', '')
internal = {host, f'www.{host}' if host else ''}
internal = {h for h in internal if h}
violations = []
for m in re.finditer(r'<a\b([^>]*)>', html, flags=re.I):
attrs = m.group(1)
href = re.search(r'href=\"([^\"]+)\"', attrs, flags=re.I)
if not href: continue
h = urlparse(href.group(1)).hostname or ''
if h and h not in internal:
rel = re.search(r'rel=\"([^\"]+)\"', attrs, flags=re.I)
rel_val = (rel.group(1) if rel else '').lower()
if 'nofollow' not in rel_val:
violations.append(href.group(1))
for v in violations: print('MISSING nofollow:', v)
print(f'{len(violations)} violation(s)')
" tmp/blog-drafts/<slug>.draft.html
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.
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.
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/<slug>-<N>-<short-name>.png.
<figure>
<img src="<image-url-or-path>" alt="<full description with all numbers and labels>" loading="lazy">
<figcaption>One sentence restating the takeaway in plain English (15-30 words).</figcaption>
</figure>
Caption rules:
<figcaption>: <a> (with rel="nofollow noopener" for external), <em>.The <img src> value depends on the publish target:
The bundle is three files that every adapter consumes:
| File | Contents |
|---|---|
| <slug>.draft.html | Body HTML (already produced in Step 3, scrubbed and audited). |
| <slug>.schema.html | JSON-LD <script> blocks (FAQPage + BreadcrumbList + optional HowTo). |
| <slug>.metadata.json | Title, slug, tags, author, meta title/description, excerpt, feature image, status, publish-at. |
Headline (becomes the SEO title unless meta_title overrides):
Slug (URL fragment):
the, a, an, for, with, in, to, of, on, and, or, is, are.n8n-1-45-2-fix goes stale; n8n-http-401-fix does not.import re
STOP = {'the','a','an','for','with','in','to','of','on','and','or','is','are'}
slug = "-".join(t for t in re.findall(r'[a-z0-9]+', topic.lower()) if t not in STOP)
slug = slug[:60].rstrip('-')
Most platforms emit Article/BlogPosting/Person/Organization schema by default. This skill adds three more for AI-citation extractability:
Home > <Primary Tag> > <Post Title>.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 <script> nodes — so JSON-LD inlined in the draft body disappears in the live page even though it was present in the POST payload.
The blocks must go in a platform-specific "head injection" slot:
| Platform | Where the schema goes |
|---|---|
| Ghost | codeinjection_head field on the post payload |
| WordPress | <head> via a theme hook, or the Yoast / Rank Math "schema" panel |
| Static-site | written directly into the rendered HTML's <head> by your build step |
Never append <script type="application/ld+json"> to the body HTML. Build it once via this step into <slug>.schema.html; the platform adapter in Step 8 reads that file and writes it into the correct field.
# Args: slug, headline, format, primary-tag-name, canonical-base-url
python3 scripts/seo-blog-writer/build-schema.py "<slug>" "<headline>" "<format>" "<primary-tag>" "<canonical-base-url>"
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.
Options:
blog-figure-svg skill (feature variant) for a 1600x840 OG card with a clean headline + brand mark.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.
Every post needs an author. The shape varies by platform; capture it generically in metadata:
"author": {"slug": "<author-slug>", "name": "<display name>"}
The adapter in Step 8 translates this to the platform's API shape:
authors: [{"slug": "<slug>"}]. Slug must match an existing user; otherwise Ghost silently substitutes the integration owner.author: <user-id> (numeric). Resolve slug -> id once and cache.author: field of the generated file.Use a flat list of tag name strings:
"tags": ["How To", "n8n"]
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.
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).
Write the per-post fields into tmp/blog-drafts/<slug>.params.json, then run the
builder. It validates required fields and maps the status flags to every adapter.
params.json shape:
{
"title": "<headline>",
"tags": ["How To", "n8n"],
"author": {"slug": "<author-slug>", "name": "<author display name>"},
"meta_title": "<SEO title under 60 chars>",
"meta_description": "<SEO description, 140-160 chars>",
"custom_excerpt": "<dek shown on index page>",
"feature_image": "",
"feature_image_alt": "",
"feature_image_caption": "",
"publish": false,
"publish_at": null
}
First tag is the primary tag (passed to 7b for the breadcrumb). Set publish: true
for --publish; publish_at (ISO-UTC) for --publish-at (mutually exclusive).
python3 scripts/seo-blog-writer/build-metadata.py "<slug>"
Before invoking the platform adapter, all of these must hold:
python3 scripts/seo-blog-writer/validate-bundle.py "<slug>"
If any assert fires, fix and re-build before Step 8.
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.
python3 scripts/seo-blog-writer/refresh-meta.py "<slug>" "<format>"
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.
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.
Skip this step if you don't have a glossary.json file yet — there's no default. See references/glossary-schema.md for the file shape and a starter example.
python3 scripts/inject-glossary-links.py \
tmp/blog-drafts/<slug>.draft.html \
--glossary path/to/glossary.json \
--base-url /glossary/ \
--max-links 6 \
> tmp/blog-drafts/<slug>.draft.linked.html
mv tmp/blog-drafts/<slug>.draft.linked.html tmp/blog-drafts/<slug>.draft.html
The injector:
--max-links (default 6), priority-sorted from the glossary.user-agent won't match agent, @scope/ai-seo-mcp won't match mcp).data-definition attribute on each link for the tooltip.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.
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 <script> tag in your site <head>) once. It's self-contained, ~1 KB, no dependencies, and skips itself on /glossary/* pages.
Pick one adapter per run. Each adapter reads the same bundle (<slug>.draft.html, <slug>.schema.html, <slug>.metadata.json) and writes the post to its target platform.
The Ghost adapter uses the Admin API over HTTPS. No Docker, no SSH — just authenticated POST to /ghost/api/admin/posts/.
Credentials:
| Env var | Source | Shape |
|---|---|---|
| GHOST_URL | Your Ghost site URL | https://blog.example.com (no trailing slash) |
| GHOST_ADMIN_KEY | Ghost admin -> Settings -> Integrations -> (your integration) -> Admin API Key | <24-hex>:<64-hex> combined |
Preflight:
curl -sS "$GHOST_URL/ghost/api/admin/site/" | head -c 80
[ -n "$GHOST_URL" ] && [ -n "$GHOST_ADMIN_KEY" ] && echo "keys present" || echo "MISSING"
Image upload (call once per figure, then splice the returned URL into the draft):
python3 scripts/seo-blog-writer/ghost-upload-image.py "<image-path>"
Publish the post:
python3 scripts/seo-blog-writer/publish-ghost.py "<slug>"
?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.
Python deps: pip install requests pyjwt. PyJWT 2.x required.
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/.
Credentials:
| Env var | Source | Shape |
|---|---|---|
| WP_URL | Your WordPress site URL | https://blog.example.com (no trailing slash) |
| WP_USER | The WP username the app password belongs to | admin |
| WP_APP_PASSWORD | Profile -> Application Passwords -> new -> "seo-blog-writer" | xxxx xxxx xxxx xxxx xxxx xxxx |
Preflight:
curl -sS "$WP_URL/wp-json/wp/v2/" | head -c 120
[ -n "$WP_URL" ] && [ -n "$WP_USER" ] && [ -n "$WP_APP_PASSWORD" ] && echo "keys present" || echo "MISSING"
Image upload (returns the media id and URL):
python3 scripts/seo-blog-writer/wp-upload-image.py "<image-path>"
Publish the post:
python3 scripts/seo-blog-writer/publish-wordpress.py "<slug>"
Notes:
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"] = <id>.<script> 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.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.
No credentials. Just a target path.
python3 scripts/seo-blog-writer/publish-static.py "<slug>" "<out-dir>"
Your SSG's layout template needs one line to include the schema in <head> — e.g. for Hugo:
{{ if (fileExists (printf "content/posts/%s.schema.html" .File.BaseFileName)) }}
{{ readFile (printf "content/posts/%s.schema.html" .File.BaseFileName) | safeHTML }}
{{ end }}
For Astro / Eleventy / Next, do the equivalent (read file at build time, inject into the layout head).
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:
<slug>.draft.html — body HTML, post-scrub, post-audit.<slug>.schema.html — JSON-LD <script> blocks to inject in <head>.<slug>.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).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.
Whatever adapter ran, the final report includes:
<base-url>/<slug>/ if published; admin edit URL if draft).--publish)# Post is reachable
curl -sSI "<base-url>/<slug>/" | head -5
# Post in RSS
curl -sS "<base-url>/rss/" | grep -o "<title>[^<]*</title>" | head -5
# Post in sitemap (path varies by platform — Ghost: /sitemap-posts.xml; WP: /sitemap.xml; SSG: as configured)
curl -sS "<base-url>/sitemap-posts.xml" | grep "<slug>"
# OG + full schema set rendered
curl -sS "<base-url>/<slug>/" | grep -o 'property="og:[^"]*"' | sort -u
curl -sS "<base-url>/<slug>/" | grep -oE '"@type":\s*"[^"]+"' | sort -u
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.
--publish-at <ISO-UTC> to schedule. Without it the post lands as draft (default) or live (--publish).blog-figure-svg skill for SVG charts, taxonomies, and flow diagrams.blog-topic-research skill to validate a topic has real demand signals before drafting.| Symptom | Adapter | Cause | Fix |
|---|---|---|---|
| 401 Unauthorized | Ghost / WordPress | Key expired / wrong key / wrong app-password | Regenerate the integration / app password |
| Ghost 422 Validation failed: Value in [posts.html] cannot be blank | Ghost | Missing ?source=html | Add the query param |
| Ghost 422 with feature_image_alt in message | Ghost | Alt text >191 chars | Trim to <=191; Step 7g asserts this |
| 404 on slug after publish | any | Post saved as draft (default) | Drafts only reachable via admin editor URL |
| Body shows as one HTML blob | Ghost | Ghost fell back to plain-text mode | Re-post with ?source=html |
| Smart quotes reappear in rendered post | Ghost | Ghost typographer auto-conversion | Settings -> Publication: turn off "Use typographer's quotes" |
| Wrong slug | any | Platform auto-slugged from title | PUT/PATCH the post with the corrected slug |
| Ghost 409 Conflict on PUT | Ghost | Stale updated_at | Re-GET to refresh, retry |
| 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 |
| 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 <slug>.schema.html; echo current updated_at to avoid 409 |
| WordPress strips <script type="application/ld+json"> from body | WordPress | User lacks unfiltered_html | Move schema injection to a theme hook reading a post meta key |
blog-topic-research — validate a long-tail topic has real demand signals (PAA, Reddit threads, GitHub issues) before drafting. Run this before this skill.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.Together, the three form a complete long-tail SEO publishing pipeline: research the topic, write the post, illustrate it, publish.
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:
# Sweep your published-content directory for non-ASCII chars + prose banlist
python3 scripts/audit-corpus.py path/to/your/content/
# Examples (per platform):
python3 scripts/audit-corpus.py tmp/blog-drafts/ # current drafts
python3 scripts/audit-corpus.py content/posts/ # Hugo / Astro / 11ty
python3 scripts/audit-corpus.py site/source/_posts/ # Jekyll
# Add domain-specific terms you want flagged (comma-separated):
python3 scripts/audit-corpus.py content/posts/ --extra "synergy,best-in-class"
# CI mode: exit 1 on any hit, pipe to your notifier or fail the build
python3 scripts/audit-corpus.py content/posts/ >/dev/null || echo "drift detected"
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.
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.