$npx -y skills add PramodDutta/qaskills --skill publish-seo-batchUse when publishing SEO blog articles to qaskills.sh, e.g. "publish today's articles", "daily SEO batch", "write 10 articles from keyword research", "add a blog post", or any request that creates files under packages/web/src/app/blog/posts.
| 1 | # Publish SEO Batch |
| 2 | |
| 3 | The daily content pipeline: source topics -> dedup slugs -> write posts -> register -> build -> commit -> deploy -> prove live -> ping IndexNow. Default batch size is 10 unless the user says otherwise. Every step has a check; a batch is not "published" until step 8 passes, then step 9 nudges the search engines. |
| 4 | |
| 5 | ## Step 0: State check |
| 6 | |
| 7 | ```bash |
| 8 | git -C /Users/promode/qaskills status --short # note pre-existing WIP; you will NOT stage it |
| 9 | date +%F # today's date, used in every post and the commit message |
| 10 | ``` |
| 11 | |
| 12 | Pre-existing modified/untracked files are the user's WIP. Leave them alone. |
| 13 | |
| 14 | ## Step 1: Source topics |
| 15 | |
| 16 | Saved GSC reports in `docs/seo/KEYWORD-OPPORTUNITIES-*.md` are historical and fully published; do not source topics from them. Instead: |
| 17 | |
| 18 | 1. If the user supplied topics or keywords, use those. |
| 19 | 2. Otherwise WebSearch for net-new opportunities in the site's clusters: Playwright (releases, features, integrations), LLM evaluation (DeepEval, Ragas, promptfoo, Langfuse), API testing, load testing, AI test generation, agentic testing, CI/CD. Favor high-intent long-tail: "X vs Y 2026", "how to X", tool + new-version guides, fresh industry reports. |
| 20 | 3. Cross-check each candidate against existing coverage (step 2). Prefer gaps over rewrites. |
| 21 | 4. If the research produced a reusable keyword dataset, save it as `docs/seo/KEYWORD-OPPORTUNITIES-YYYY-MM.md` and include it in the commit. |
| 22 | |
| 23 | ## Step 2: Dedup every slug (MANDATORY before writing any file) |
| 24 | |
| 25 | Batch arrays are spread into the registries LAST, so a colliding slug silently replaces a real article. This has shipped a stub over a full article before. |
| 26 | |
| 27 | ```bash |
| 28 | cd /Users/promode/qaskills |
| 29 | for s in slug-one slug-two; do |
| 30 | hits=$(grep -rn "$s" packages/web/src/app/blog/posts/ | grep -v "$s-something-longer" | wc -l) |
| 31 | echo "$s: $hits hits" |
| 32 | done |
| 33 | ``` |
| 34 | |
| 35 | Any hit (filename, `posts` map key, `postList` entry, batch array entry): choose a different slug or drop the topic. Also scan titles in `posts/index.ts` for near-duplicate topics; a second article on the same query cannibalizes the first. |
| 36 | |
| 37 | ## Step 3: Write each post |
| 38 | |
| 39 | File: `packages/web/src/app/blog/posts/<slug>.ts` |
| 40 | |
| 41 | ```ts |
| 42 | import type { BlogPost } from './index'; |
| 43 | |
| 44 | export const post: BlogPost = { |
| 45 | title: 'Primary Keyword In Natural Title', |
| 46 | description: 'Meta description, 140-170 chars, contains the keyword, states the payoff.', |
| 47 | date: 'YYYY-MM-DD', // today |
| 48 | category: 'Guide', // Guide | Reference | Tutorial | Comparison | AI Testing | API Testing | Migration | BDD | Performance |
| 49 | content: ` |
| 50 | # Same Title As Above |
| 51 | |
| 52 | Opening paragraphs that answer the query directly... |
| 53 | `, |
| 54 | }; |
| 55 | ``` |
| 56 | |
| 57 | Per-article bar (all required): |
| 58 | - Content >= 1200 words; H1 equals `title` |
| 59 | - At least one markdown table (comparison, metrics, or reference) |
| 60 | - Code blocks for technical topics; ESCAPE backticks inside the template literal (\\\`\\\`\\\`) |
| 61 | - At least 2 internal links to existing posts (`/blog/<existing-slug>`); verify each target exists in `posts/index.ts` before linking |
| 62 | - No em dashes anywhere; no invented statistics (attribute figures or mark them approximate) |
| 63 | - No `${` in prose unless intentionally interpolating; escape as \\${ if literal |
| 64 | - End with a `## Frequently Asked Questions` section (3-4 `### Question?` H3s with short answers) as the LAST H2. It counts toward the word bar and `src/lib/extract-faqs.ts` turns it into FAQPage JSON-LD for AI-search citation. Without it, articles tend to land short of 1200 words. |
| 65 | |
| 66 | ## Step 4: Register in BOTH registries |
| 67 | |
| 68 | `packages/web/src/app/blog/posts/index.ts` needs three edits per post: |
| 69 | 1. Import: `import { post as camelCaseName } from './<slug>';` (with the other imports) |
| 70 | 2. `posts` map entry: `'<slug>': camelCaseName,` placed BEFORE the batch `...Object.fromEntries` spreads |
| 71 | 3. `postList` entry: `{ slug: '<slug>', ...camelCaseName },` placed before the batch spreads at the end |
| 72 | |
| 73 | Missing 1 or 2 = the page 404s. Missing 3 = invisible on /blog and absent from the sitemap. Do not touch `sitemap.ts`; it derives from `postList`. |
| 74 | |
| 75 | ## Step 5: Verify locally (do not skip) |
| 76 | |
| 77 | ```bash |
| 78 | cd /Users/promode/qaskills |
| 79 | # exactly 2 quoted occurrences per slug in index.ts (map key + postList) |
| 80 | for s in slug-one slug-two; do |
| 81 | c=$(grep -c "'$s'" packages/web/src/app/blog/posts/index.ts) |
| 82 | [ "$c" -eq 2 ] || echo "REGISTRATION WRONG: $s count=$c" |
| 83 | done |
| 84 | # no em dashes in the new files |
| 85 | grep -l '—' packages/web/src/app/blog/posts/<each-new-slug>.ts && echo "EM DASH FOUND" |
| 86 | # build must pass |
| 87 | pnpm --filter @qaskills/shared build && pnpm --filter @qaskills/web build |
| 88 | ``` |
| 89 | |
| 90 | Fix and re-run until all three are clean. Build failure in a post file is almost always an unescaped backtick or |