seo-blog-images
Plan, generate, compress, verify, and wire high-CTR cover images and inline proof visuals into blog posts (MDX, Astro, Next.js, Hugo, HTML) with strict image SEO checks. Use when adding a blog cover or OG image, generating inline visuals without dark icon-box AI slop, capturing real product screenshots or data charts, or auditing image alt text, filenames, and Core Web Vitals attributes.
How do I install this agent skill?
npx skills add https://github.com/illyism/seo-skills --skill seo-blog-imagesIs this agent skill safe to install?
- Gen Agent Trust Hubpass
The skill provides a comprehensive workflow for creating and optimizing blog images for SEO. It utilizes reputable tools like Playwright for screenshots and the Sharp library for image compression. It facilitates image generation through a standard API integration with OpenRouter, following security best practices by using environment variables for API keys.
- Socketpass
No alerts
- Snykwarn
Risk: MEDIUM · 1 issue
What does this agent skill do?
Blog images and image SEO
The default AI blog visual hurts the post: a dark background with 5 glowing wireframe icons, garbled infographic labels, a 2.4 MB PNG named image-1.png, and no width/height, so the layout jumps on load. This workflow picks the right visual type per slot, produces it (real capture, deterministic chart, or a short-prompt generation), reads every letter back, and wires SEO-complete tags into the post.
Step 0: Find where images live
Which framework is the repo?
├── `next.config.*`, `astro.config.*`, `vite.config.*`, Remix -> files in `public/blog/<slug>/`, src `/blog/<slug>/<name>.webp`
├── Hugo (`hugo.toml` / `config.toml`) -> page bundle next to `index.md`, or `static/blog/<slug>/`
├── Jekyll (`_config.yml`) -> `assets/blog/<slug>/`
└── Other -> match the path pattern of an existing image in a published post
Never invent a new image directory when the repo already has one; mixed locations break the size audit and CDN caching rules.
Step 1: Pick the visual type per slot
Walk this for the cover and for each <h2> that needs a visual:
What is this slot for?
├── Software product review or UI tutorial (`### 1. ToolName`)?
│ └── Path B: real Playwright screenshot or short `<video>` loop.
│ Never ask an image model to draw a real product's UI; readers spot fake dashboards and it misrepresents the product.
├── Benchmark, dataset, or any chart with numbers?
│ └── Path C: deterministic SVG chart from the real numbers.
│ Never ask an image model for charts or tables; it invents bar heights and misspells values.
├── Cover / OpenGraph (`og:image`)?
│ └── Path A: 16:9, 12-22 word prompt, 1 focal scene + a 3-6 word headline badge.
└── Inline concept (workflow, pricing contrast, architecture)?
├── Does a 2-4 word contrast label make the point in half a second?
│ └── Path A: topic-literal scene + 2-4 quoted labels (`"MANUAL CSV"` vs `"LIVE WEBHOOK"`). No invented numbers.
└── Otherwise -> Path A: zero-text close-up, 12-20 words, tight framing.
Step 2: Prompt rules (Path A only)
- 12-22 words. Long prompts make image models crowd the frame with sub-panels and tiny unreadable text; short prompts produce one bold subject.
- No dark icon-box templates. Never prompt for
"cards with glowing wireframe icons","holographic brain","dark monochrome desk with ice-blue highlights", or trophy metaphors. Describe tangible objects under bright, high-contrast light. - Cover headline badge: 3-6 quoted words, using the target query plus a year or format badge, kept inside the center 80% of the frame so the 1.91:1 OG crop on X and LinkedIn does not cut it.
querybench.dev:"Crisp daylight 3D studio, cobalt Postgres server rack splitting into instant branches, bold headline 'POSTGRES CI BRANCHING', badge '2026 BENCHMARK'."deployfast.dev:"High-contrast isometric shipping container launching from a GitHub terminal into orbit, bold text '11 BEST NEXT.JS HOSTS', badge '2026 TESTED'."
- Inline contrast labels: 2-4 quoted labels, only real numbers. Any figure in a label must exist in the post or
seo/kb/.invoicekit.io:"Split studio scene: tangled red paper receipts labeled '0.8% BILLING FEE' versus a clean cobalt API card labeled '$19 FLAT', crisp daylight."
Step 3: Produce the file
Scaffold the image script (once per repo)
If scripts/seo-image.mjs does not exist, create it. It handles both generation (Path A) and compression of any existing PNG (Paths B and C), so every image goes through the same size pipeline.
// scripts/seo-image.mjs
// Generate: node scripts/seo-image.mjs --prompt "<12-22 words>" --out public/blog/<slug>/<keyword-name>.webp
// Compress: node scripts/seo-image.mjs --input /tmp/raw.png --out public/blog/<slug>/<keyword-name>.webp
// Options: --quality 82 (lower it if the file is over budget), --model <openrouter-image-model>
import fs from "node:fs/promises";
import path from "node:path";
import { parseArgs } from "node:util";
import sharp from "sharp";
const { values: a } = parseArgs({
options: {
prompt: { type: "string" },
input: { type: "string" },
out: { type: "string" },
quality: { type: "string", default: "82" },
model: { type: "string", default: "google/gemini-2.5-flash-image" },
},
});
if (!a.out || (!a.prompt && !a.input)) {
console.error("Usage: --prompt <text> | --input <file>, plus --out <path.webp|.jpg>");
process.exit(1);
}
let raw;
if (a.input) {
raw = await fs.readFile(a.input);
} else {
if (!process.env.OPENROUTER_API_KEY) {
console.error("OPENROUTER_API_KEY is not set");
process.exit(1);
}
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: a.model,
messages: [{ role: "user", content: a.prompt }],
modalities: ["image", "text"],
image_config: { aspect_ratio: "16:9" },
}),
});
const data = await res.json();
const url = data?.choices?.[0]?.message?.images?.[0]?.image_url?.url;
if (!res.ok || !url) {
console.error(`Generation failed (${res.status}): ${JSON.stringify(data).slice(0, 300)}`);
process.exit(1);
}
raw = Buffer.from(url.replace(/^data:image\/\w+;base64,/, ""), "base64");
}
await fs.mkdir(path.dirname(a.out), { recursive: true });
const q = Number(a.quality);
const img = sharp(raw).resize({ width: 1536, withoutEnlargement: true });
const buf = /\.jpe?g$/i.test(a.out)
? await img.jpeg({ quality: q, mozjpeg: true }).toBuffer()
: await img.webp({ quality: q }).toBuffer();
await fs.writeFile(a.out, buf);
const { width, height } = await sharp(buf).metadata();
console.log(`${a.out} ${width}x${height} ${Math.round(buf.length / 1024)} KB`);
Install sharp with the repo's package manager: pnpm add -D sharp (pnpm-lock.yaml), bun add -d sharp (bun.lock), yarn add -D sharp (yarn.lock), else npm i -D sharp. If the repo has no package.json (Hugo, Jekyll), put the script in /tmp/seo-image/, run npm init -y && npm i sharp there, and pass absolute --out paths, so the repo gains no Node tooling it does not use.
If OPENROUTER_API_KEY is missing, generate with the agent's built-in image tool, save the PNG to /tmp/, and run the script in --input mode.
Path A: generate
node scripts/seo-image.mjs --prompt "<prompt from Step 2>" --out public/blog/<slug>/<keyword-name>.webp
Use .jpg for the cover when the site's OG pipeline rejects WebP; use .webp everywhere else.
Path B: real screenshot
npx playwright install chromium # first run only
npx playwright screenshot --viewport-size "1440, 900" --wait-for-timeout 1500 \
"https://querybench.dev" /tmp/raw-shot.png
node scripts/seo-image.mjs --input /tmp/raw-shot.png --out public/blog/postgres-tools/querybench-explain-analyze-ui.webp
Path C: deterministic chart
For numbers from the post or seo/kb/proof/benchmarks/*.md, write a self-contained 1536x864 SVG: bar lengths computed from the values (cap the longest horizontal bar at 760px so right-hand value labels never clip at the 1536px edge), every value printed as a label, font-family="system-ui, -apple-system, sans-serif", dark text on a light background. Save it as /tmp/<name>.svg, then convert with --input /tmp/<name>.svg. Every number on the chart then matches the source exactly.
Step 4: Read back every image
Open every generated file with the agent's file-viewing tool right after saving. Image models misspell ("BENCHMMARK"), duplicate letters, and clip text at the edge.
- Check every quoted word letter by letter and confirm it sits fully inside the frame.
- Garbled, blurry, duplicated, or clipped text, or a dark icon-box layout: delete the file and regenerate with a 12-16 word prompt. After 2 failed attempts, switch that slot to zero-text mode.
Step 5: Wire it in with full image SEO
- Keyword kebab-case filenames naming the subject (
postgres-zfs-branch-spinup-benchmark.webp). Neverimage1.png,hero.webp,screenshot-2026.png, or UUIDs, because the filename is one of the few text signals image search reads. alttext of 80-160 characters stating what the image shows in the post's terms. Never start with"Image of","Picture of", or"An infographic showing"; screen readers already announce the image.- Bad:
"An infographic of database branching speed"(43 chars, generic). - Good:
"Comparison of 50GB Postgres CI branch creation times showing ZFS copy-on-write cloning in 1.8 seconds versus 14 minutes for pg_restore"(134 chars).
- Bad:
- Explicit
widthandheightequal to the intrinsic pixels the script printed, on every<img>and<Image>, so the browser reserves the space and the layout does not shift. - Loading priority:
- Cover / above-the-fold hero only:
priority(Next.js) orfetchpriority="high"+loading="eager", because it is the LCP element. - Every image below the fold:
loading="lazy"anddecoding="async". Neverpriorityon body images; it competes with the hero for bandwidth.
- Cover / above-the-fold hero only:
- Size budgets:
- Cover: 1200px or wider, 350 KB or less. Also set it in frontmatter (
image/cover/openGraph.images) so X, LinkedIn, Slack, and Google Discover render the large card. - Inline: 220 KB or less,
.webp. Over budget: re-run with--quality 72, then--quality 62.
- Cover: 1200px or wider, 350 KB or less. Also set it in frontmatter (
- Placement: put inline images after the first paragraph of their
<h2>section, never between the heading and its first sentence. Featured snippet extraction and readers both expect the text answer directly under the heading.
## How much faster is ZFS copy-on-write branching than pg_restore in CI?
Cloning a 50.4 GB Postgres 16 database with `pg_restore -j 4` takes a median of 14 minutes and 22 seconds in GitHub Actions. A ZFS copy-on-write snapshot boots the same 180-million-row dataset in 1.8 seconds.
<Image
src="/blog/postgres-ci-testing/postgres-zfs-branch-spinup-benchmark.webp"
alt="Benchmark comparison showing QueryBench ZFS copy-on-write booting a 50GB Postgres branch in 1.8 seconds compared to 14 minutes for pg_restore"
width={1536}
height={864}
loading="lazy"
decoding="async"
/>
Step 6: Audit before finishing
dir=public/blog/<slug>; post=content/blog/<slug>.mdx
for f in "$dir"/*; do
n=$(basename "$f"); kb=$(( $(wc -c < "$f") / 1024 ))
echo "$kb KB $n"
[ "$kb" -gt 350 ] && echo " FAIL: over 350 KB"
[ "$kb" -gt 220 ] && echo " CHECK: only the cover may exceed 220 KB"
echo "$n" | grep -qiE '^(image|img|hero|screenshot|untitled)[-_.0-9]|^[0-9a-f]{8}-' && echo " FAIL: generic filename"
done
grep -oE 'alt="[^"]*"' "$post" | awk '{ l = length($0) - 6; s = (l < 80 || l > 160) ? "FAIL" : "ok"; print s, l, $0 }'
Done means:
- Image files exist in the framework's image directory and the post file references them.
- Each slot used the right path: screenshot for products, SVG chart for numbers, short prompt for cover and concepts.
- Every generated image was viewed with zero misspelled or clipped text.
- The audit prints no
FAIL, and the only file over 220 KB is the cover. - The cover is in frontmatter/OG metadata; inline images sit after the first paragraph of their
<h2>withwidth,height, andloading="lazy".
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/illyism/seo-skills/seo-blog-images">View seo-blog-images on skillZs</a>