SEO Optimization Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Поднять органический трафик CareerClue из Google/Yandex через технические SEO-правки и on-page backfill 291 поста.
Architecture: Сначала быстрые глобальные code-правки (robots.txt, sitemap lastmod, BreadcrumbList JSON-LD) — применяются ко всем 293 постам сразу. Затем идемпотентный Node-скрипт прогоняет LLM (OpenRouter free) по постам без description, проставляя meta description + реальные теги. Тот же скрипт служит форвард-механизмом для будущих постов.
Tech Stack: Docusaurus 3.10, React 19, TypeScript, Node ≥18 (CommonJS-скрипты, глобальный fetch), vitest, OpenRouter API.
Global Constraints
- Сайт URL:
https://careerclue.vercel.app(домен НЕ меняется). - Язык контента: ru. Title постов НЕ трогаем.
- FAQPage/QAPage schema НЕ добавляем.
- OG-картинки per-post — вне этого плана (Фаза 4, отдельно).
- Тела постов НЕ переписываются — меняется только frontmatter (
description,tags). - Тесты: vitest, co-located
*.test.ts/*.test.js, импорт{describe,it,expect}из'vitest'. - Скрипты: CommonJS (
require/module.exports), стиль какscripts/build-blog-index.js. - Коммиты: conventional commits, без AI-атрибуции.
- LLM-ключ:
OPENROUTER_API_KEYиз env (тот же, что у Go-генератора). - Разрешённый словарь тегов: тема
{go, frontend, react, javascript, typescript, system_design, backend}+ грейд{junior, middle, senior}.
Task 1: robots.txt + sitemap lastmod (Фаза 0)
Files:
- Create:
static/robots.txt - Modify:
docusaurus.config.ts(presetclassicoptions — добавить блокsitemap)
Interfaces:
-
Consumes: ничего.
-
Produces:
build/robots.txtсSitemap:директивой;build/sitemap.xmlс<lastmod>в url-записях постов. -
Step 1: Создать
static/robots.txt
User-agent: *
Allow: /
Sitemap: https://careerclue.vercel.app/sitemap.xml
- Step 2: Добавить sitemap-конфиг в preset
В docusaurus.config.ts, внутри presets → 'classic' → объекта опций (там где blog и theme), добавить рядом с ними ключ sitemap:
sitemap: {
lastmod: 'date',
changefreq: 'weekly',
priority: 0.5,
},
Вставить между блоком blog: {...} (после его закрывающей },) и блоком theme: {...}.
- Step 3: Собрать сайт
Run: npm run build
Expected: сборка без ошибок, в конце [SUCCESS] Generated static files in "build".
- Step 4: Проверить robots.txt в build
Run: cat build/robots.txt
Expected: содержимое из Step 1, включая строку Sitemap: https://careerclue.vercel.app/sitemap.xml.
- Step 5: Проверить lastmod в sitemap
Run: grep -c '<lastmod>' build/sitemap.xml
Expected: число > 0 (у постов появился <lastmod>).
- Step 6: Commit
git add static/robots.txt docusaurus.config.ts
git commit -m "feat(seo): add robots.txt and sitemap lastmod"
Task 2: BreadcrumbList JSON-LD (Фаза 1)
Files:
- Create:
src/theme/BlogPostPage/index.tsx(swizzle-wrap оригинального компонента)
Interfaces:
-
Consumes:
props.content.metadata(permalink, title) — Docusaurus передаёт загруженный MDX-компонент поста какcontent;siteConfig.urlизuseDocusaurusContext(). -
Produces: второй
<script type="application/ld+json">со схемойBreadcrumbListв<head>каждого поста (в дополнение к авто-BlogPosting). -
Step 1: Создать обёртку
src/theme/BlogPostPage/index.tsx
import React from 'react';
import BlogPostPage from '@theme-original/BlogPostPage';
import type BlogPostPageType from '@theme/BlogPostPage';
import type {WrapperProps} from '@docusaurus/types';
import Head from '@docusaurus/Head';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
type Props = WrapperProps<typeof BlogPostPageType>;
export default function BlogPostPageWrapper(props: Props): React.ReactElement {
const {siteConfig} = useDocusaurusContext();
const {metadata} = (props as any).content;
const base = siteConfig.url;
const breadcrumb = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{'@type': 'ListItem', position: 1, name: 'Главная', item: `${base}/`},
{'@type': 'ListItem', position: 2, name: 'Разборы', item: `${base}/blog`},
{
'@type': 'ListItem',
position: 3,
name: metadata.title,
item: `${base}${metadata.permalink}`,
},
],
};
return (
<>
<Head>
<script type="application/ld+json">
{JSON.stringify(breadcrumb)}
</script>
</Head>
<BlogPostPage {...props} />
</>
);
}
- Step 2: Собрать сайт
Run: npm run build
Expected: сборка без ошибок (нет TypeScript/broken-links падений).
- Step 3: Проверить, что в посте два JSON-LD блока
Run: p=$(find build/blog -name index.html | grep -E '[0-9]{4}/[0-9]{2}/[0-9]{2}' | grep -vE '/(tags|page|archive|authors)/' | head -1); grep -c 'application/ld+json' "$p"
Expected: 2 (авто-BlogPosting + новый BreadcrumbList).
- Step 4: Проверить содержимое BreadcrumbList
Run: p=$(find build/blog -name index.html | grep -E '[0-9]{4}/[0-9]{2}/[0-9]{2}' | grep -vE '/(tags|page|archive|authors)/' | head -1); grep -o 'BreadcrumbList' "$p"
Expected: BreadcrumbList (схема присутствует в разметке поста).
- Step 5: Commit
git add src/theme/BlogPostPage/index.tsx
git commit -m "feat(seo): emit BreadcrumbList JSON-LD on blog posts"
Task 3: backfill — чистые хелперы + тесты (Фаза 2, TDD)
Files:
- Create:
scripts/seo-backfill.js(хелперы +main, но в этой задаче только хелперы и экспорт) - Test:
scripts/seo-backfill.test.js
Interfaces:
-
Produces (экспортируются через
module.exports, потребляются Task 4 и тестами):parseFrontmatter(text) -> {data: object, body: string, fmRaw: string, hasFm: boolean}upsertFrontmatter(text, {description, tags}) -> string— заменяет/добавляет строкиdescription:иtags:во frontmatter, тело и прочие поля не трогает.extractJson(llmText) -> object— парсит JSON, в т.ч. обёрнутый в```jsonfences; кидаетError('no JSON')если не найден.sanitizeTags(rawTags) -> string[]— фильтрует по словарю, дедуп, сохраняет порядок; если пусто →['go'].needsBackfill(data) -> boolean—true, если у поста нет непустогоdescription.ALLOWED_TAGS: string[]— словарь из Global Constraints.
-
Step 1: Написать падающий тест
scripts/seo-backfill.test.js
import {describe, it, expect} from 'vitest';
import {
parseFrontmatter,
upsertFrontmatter,
extractJson,
sanitizeTags,
needsBackfill,
} from './seo-backfill.js';
const POST = `---
title: "Собеседование FRONTEND"
authors: [UCabc]
tags: [go]
---
<YouTube id="x"/>
Тело поста.`;
describe('parseFrontmatter', () => {
it('splits frontmatter and body', () => {
const {data, body, hasFm} = parseFrontmatter(POST);
expect(hasFm).toBe(true);
expect(data.title).toBe('Собеседование FRONTEND');
expect(data.tags).toBe('[go]');
expect(body).toContain('Тело поста.');
});
it('handles missing frontmatter', () => {
expect(parseFrontmatter('no fm here').hasFm).toBe(false);
});
});
describe('needsBackfill', () => {
it('true when no description', () => {
expect(needsBackfill({title: 'x'})).toBe(true);
});
it('false when description present', () => {
expect(needsBackfill({description: 'уже есть'})).toBe(false);
});
});
describe('upsertFrontmatter', () => {
it('adds description and replaces tags, keeps title/authors/body', () => {
const out = upsertFrontmatter(POST, {
description: 'Разбор собеседования frontend: вопросы и ответы.',
tags: ['frontend', 'react'],
});
expect(out).toContain('description: "Разбор собеседования frontend: вопросы и ответы."');
expect(out).toContain('tags: [frontend, react]');
expect(out).not.toContain('tags: [go]');
expect(out).toContain('title: "Собеседование FRONTEND"');
expect(out).toContain('authors: [UCabc]');
expect(out).toContain('Тело поста.');
});
it('escapes double quotes in description', () => {
const out = upsertFrontmatter(POST, {
description: 'Он сказал "привет" всем',
tags: ['go'],
});
expect(out).toContain('description: "Он сказал \\"привет\\" всем"');
});
});
describe('extractJson', () => {
it('parses fenced json', () => {
const t = 'Вот результат:\n```json\n{"description":"d","tags":["go"]}\n```';
expect(extractJson(t)).toEqual({description: 'd', tags: ['go']});
});
it('parses raw json', () => {
expect(extractJson('{"description":"d","tags":["react"]}')).toEqual({
description: 'd',
tags: ['react'],
});
});
it('throws when no json', () => {
expect(() => extractJson('нет json тут')).toThrow('no JSON');
});
});
describe('sanitizeTags', () => {
it('drops unknown, dedupes, preserves order', () => {
expect(sanitizeTags(['react', 'react', 'banana', 'junior'])).toEqual([
'react',
'junior',
]);
});
it('falls back to [go] when empty', () => {
expect(sanitizeTags(['banana'])).toEqual(['go']);
});
});
- Step 2: Запустить тест — убедиться, что падает
Run: npx vitest run scripts/seo-backfill.test.js
Expected: FAIL — Failed to resolve import "./seo-backfill.js" (файла ещё нет).
- Step 3: Реализовать хелперы в
scripts/seo-backfill.js
#!/usr/bin/env node
const ALLOWED_TAGS = [
'go', 'frontend', 'react', 'javascript', 'typescript',
'system_design', 'backend', 'junior', 'middle', 'senior',
];
function parseFrontmatter(text) {
if (!text.startsWith('---')) return {data: {}, body: text, fmRaw: '', hasFm: false};
const end = text.indexOf('\n---', 3);
if (end === -1) return {data: {}, body: text, fmRaw: '', hasFm: false};
const fmRaw = text.slice(3, end);
const body = text.slice(end + 4);
const data = {};
for (const raw of fmRaw.split('\n')) {
const m = raw.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
if (!m) continue;
let v = m[2].trim();
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
else if (v.startsWith("'") && v.endsWith("'")) v = v.slice(1, -1);
data[m[1]] = v;
}
return {data, body, fmRaw, hasFm: true};
}
function needsBackfill(data) {
return !(data.description && data.description.trim().length > 0);
}
function sanitizeTags(rawTags) {
const seen = new Set();
const out = [];
for (const t of Array.isArray(rawTags) ? rawTags : []) {
const tag = String(t).trim().toLowerCase();
if (ALLOWED_TAGS.includes(tag) && !seen.has(tag)) {
seen.add(tag);
out.push(tag);
}
}
return out.length ? out : ['go'];
}
function extractJson(llmText) {
const fence = llmText.match(/```(?:json)?\s*([\s\S]*?)```/);
const candidate = fence ? fence[1] : llmText;
const start = candidate.indexOf('{');
const stop = candidate.lastIndexOf('}');
if (start === -1 || stop === -1 || stop < start) throw new Error('no JSON');
return JSON.parse(candidate.slice(start, stop + 1));
}
function upsertFrontmatter(text, {description, tags}) {
const {fmRaw, body, hasFm} = parseFrontmatter(text);
const lines = (hasFm ? fmRaw.split('\n') : [])
.filter((l) => l.trim() !== '')
.filter((l) => !/^description\s*:/.test(l))
.filter((l) => !/^tags\s*:/.test(l));
const esc = description.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
lines.push(`description: "${esc}"`);
lines.push(`tags: [${tags.join(', ')}]`);
return `---\n${lines.join('\n')}\n---${body}`;
}
module.exports = {
ALLOWED_TAGS,
parseFrontmatter,
needsBackfill,
sanitizeTags,
extractJson,
upsertFrontmatter,
};
- Step 4: Запустить тесты — убедиться, что проходят
Run: npx vitest run scripts/seo-backfill.test.js
Expected: PASS — все тесты зелёные.
- Step 5: Commit
git add scripts/seo-backfill.js scripts/seo-backfill.test.js
git commit -m "feat(seo): add frontmatter backfill helpers with tests"
Task 4: backfill — LLM-раннер (Фаза 2)
Files:
- Modify:
scripts/seo-backfill.js(добавитьbuildPrompt,callLLM,main, CLI-парсинг)
Interfaces:
-
Consumes: хелперы из Task 3; env
OPENROUTER_API_KEY; глобальныйfetch. -
Produces: CLI
node scripts/seo-backfill.js [--limit N] [--force] [--dry-run] [--batch N], который правит frontmatter постовblog/*.md*и коммитит батчами. Новая экспортируемая функцияbuildPrompt(title, body) -> string(тестируемая). -
Step 1: Написать падающий тест на
buildPrompt
Дописать в конец scripts/seo-backfill.test.js:
import {buildPrompt} from './seo-backfill.js';
describe('buildPrompt', () => {
it('includes title and truncated body and asks for JSON', () => {
const p = buildPrompt('Собеседование Go', 'a'.repeat(5000));
expect(p).toContain('Собеседование Go');
expect(p).toContain('description');
expect(p).toContain('tags');
// тело обрезается до 2000 символов
expect(p.length).toBeLessThan(3000);
});
});
- Step 2: Запустить — убедиться, что падает
Run: npx vitest run scripts/seo-backfill.test.js
Expected: FAIL — buildPrompt is not a function / import не резолвится.
- Step 3: Добавить
buildPrompt,callLLM,mainвscripts/seo-backfill.js
Перед module.exports вставить:
const fs = require('fs');
const path = require('path');
const {execSync} = require('child_process');
const BLOG_DIR = path.resolve(__dirname, '..', 'blog');
const FNAME_RE = /\.(md|mdx)$/;
const MODELS = [
'meta-llama/llama-3.3-70b-instruct:free',
'qwen/qwen-2.5-72b-instruct:free',
'deepseek/deepseek-chat:free',
];
function buildPrompt(title, body) {
const snippet = body.slice(0, 2000);
return [
'Ты SEO-редактор. По заголовку и телу поста-разбора собеседования верни СТРОГО JSON',
'без пояснений: {"description": "...", "tags": ["..."]}',
'- description: 150-160 символов, на русском, keyword-first, уникальный, без обрыва.',
`- tags: только из списка ${JSON.stringify(ALLOWED_TAGS)}; 1-3 тега по реальной теме (язык/стек) + грейд если явен.`,
`Заголовок: ${title}`,
`Тело: ${snippet}`,
].join('\n');
}
async function callLLM(prompt) {
const key = process.env.OPENROUTER_API_KEY;
if (!key) throw new Error('OPENROUTER_API_KEY not set');
let lastErr;
for (const model of MODELS) {
try {
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model,
messages: [{role: 'user', content: prompt}],
temperature: 0.3,
}),
});
if (!res.ok) throw new Error(`${model}: HTTP ${res.status}`);
const json = await res.json();
const content = json.choices?.[0]?.message?.content;
if (!content) throw new Error(`${model}: empty content`);
return extractJson(content);
} catch (e) {
lastErr = e;
console.warn(` ⚠ ${e.message}`);
}
}
throw lastErr;
}
function parseArgs(argv) {
const a = {limit: Infinity, force: false, dryRun: false, batch: 25};
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--limit') a.limit = parseInt(argv[++i], 10);
else if (argv[i] === '--force') a.force = true;
else if (argv[i] === '--dry-run') a.dryRun = true;
else if (argv[i] === '--batch') a.batch = parseInt(argv[++i], 10);
}
return a;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const files = fs.readdirSync(BLOG_DIR).filter((f) => FNAME_RE.test(f));
let processed = 0;
let sinceCommit = 0;
for (const f of files) {
if (processed >= args.limit) break;
const full = path.join(BLOG_DIR, f);
const text = fs.readFileSync(full, 'utf8');
const {data, body} = parseFrontmatter(text);
if (!args.force && !needsBackfill(data)) continue;
console.log(`→ ${f}`);
let out;
try {
out = await callLLM(buildPrompt(data.title || f, body));
} catch (e) {
console.error(` ✗ FAIL ${f}: ${e.message}`);
continue;
}
const tags = sanitizeTags(out.tags);
const description = String(out.description || '').trim();
if (!description) {
console.error(` ✗ empty description ${f}`);
continue;
}
console.log(` ✓ tags=[${tags.join(', ')}] desc="${description.slice(0, 60)}…"`);
if (!args.dryRun) {
fs.writeFileSync(full, upsertFrontmatter(text, {description, tags}), 'utf8');
sinceCommit++;
if (sinceCommit >= args.batch) {
execSync('git add -A blog', {stdio: 'inherit'});
execSync(`git commit -q -m "chore(seo): backfill description+tags batch"`, {stdio: 'inherit'});
sinceCommit = 0;
}
}
processed++;
}
if (!args.dryRun && sinceCommit > 0) {
execSync('git add -A blog', {stdio: 'inherit'});
execSync(`git commit -q -m "chore(seo): backfill description+tags batch"`, {stdio: 'inherit'});
}
console.log(`Done. Processed ${processed}.`);
}
if (require.main === module) {
main().catch((e) => {
console.error(e);
process.exit(1);
});
}
И добавить buildPrompt в module.exports:
module.exports = {
ALLOWED_TAGS,
parseFrontmatter,
needsBackfill,
sanitizeTags,
extractJson,
upsertFrontmatter,
buildPrompt,
};
- Step 4: Запустить тесты — убедиться, что проходят
Run: npx vitest run scripts/seo-backfill.test.js
Expected: PASS — включая новый buildPrompt-тест.
- Step 5: Dry-run на 2 постах (реальный LLM, без записи)
Run: OPENROUTER_API_KEY=$OPENROUTER_API_KEY node scripts/seo-backfill.js --limit 2 --dry-run
Expected: для 2 постов печатает ✓ tags=[...] desc="..."; файлы НЕ изменены (git status --short blog пусто).
- Step 6: Commit скрипта
git add scripts/seo-backfill.js scripts/seo-backfill.test.js
git commit -m "feat(seo): add OpenRouter backfill runner"
Task 5: Прогон backfill + верификация корпуса + тег-хабы (Фаза 2–3)
Files:
- Modify:
blog/*.md*(только frontmatter — через скрипт из Task 4)
Interfaces:
-
Consumes:
scripts/seo-backfill.js. -
Produces: ~291 постов с
descriptionи осмысленнымиtags; тег-хабы/blog/tags/<tag>в sitemap. -
Step 1: Прогон на выборке 10 + ручная проверка
Run: node scripts/seo-backfill.js --limit 10
Expected: 10 постов обработаны, батч-коммит(ы) созданы.
Проверить осмысленность:
Run: git show --stat HEAD | head; grep -l '^description:' blog/*.md* | head -3 | xargs grep -H '^description:\|^tags:'
Expected: description релевантен теме поста; tags соответствуют реальному стеку (не поголовно go).
- Step 2: Полный прогон
Run: node scripts/seo-backfill.js
Expected: обработаны все оставшиеся посты без description; серия батч-коммитов; в конце Done. Processed N..
- Step 3: Проверить покрытие description
Run: echo "with desc: $(grep -l '^description:' blog/*.md* | wc -l)"; echo "total: $(ls blog/*.md* | wc -l)"
Expected: число постов с description ≈ общему числу постов (допустимы единичные fail-ы — см. лог).
- Step 4: Проверить распределение тегов
Run: grep -rhoE '^tags: \[.*\]' blog/*.md* | sort | uniq -c | sort -rn | head
Expected: распределение больше НЕ 282×[go] — появились frontend/react/javascript/system_design и т.д.
- Step 5: Сборка + тег-хабы в sitemap
Run: npm run build && grep -oE '/blog/tags/[a-z_]+' build/sitemap.xml | sort -u
Expected: сборка зелёная; в sitemap присутствуют tag-хаб URL для новых тегов (напр. /blog/tags/frontend, /blog/tags/react).
- Step 6: Финальный коммит (если остались незакоммиченные хвосты)
git add -A blog
git commit -m "chore(seo): backfill description+tags (final)" || echo "nothing to commit"
Форвард-механизм (замена spec §2.2): Go-генератор НЕ правим. Новые посты выходят без
description, значит идемпотентныйseo-backfill.jsподхватит их при следующем прогоне и проставит description+теги, перезаписав дефолтныйtags: [go]. Это DRY (одна логика классификации) и не требует пересборки Go-бинаря. Опциональная косметика на будущее: вmock/chatgtp/usecase.go:379захардкоженtags: [go]— можно убрать, но это не обязательно, т.к. backfill всё равно перетирает теги.
Task 6: Off-page чеклист (документация)
Files:
- Create:
docs/superpowers/specs/2026-07-18-seo-offpage-checklist.md
Interfaces:
-
Consumes: ничего.
-
Produces: чеклист ручных действий пользователя (доступ к панелям вебмастеров).
-
Step 1: Создать чеклист
# SEO off-page чеклист (ручные действия)
После деплоя изменений:
- [ ] Google Search Console → Sitemaps → добавить `https://careerclue.vercel.app/sitemap.xml`.
- [ ] Yandex Webmaster → Индексирование → Файлы Sitemap → добавить тот же URL.
- [ ] Yandex Webmaster → проверить, что robots.txt распознан (Инструменты → Анализ robots.txt).
- [ ] Прогнать IndexNow по изменённым URL: `npm run indexnow`.
- [ ] Через 1–2 недели: GSC → Страницы → проверить рост «Проиндексировано».
- [ ] GSC → Enhancements → убедиться, что Breadcrumbs распознаны без ошибок.
- [ ] PageSpeed Insights по 2–3 постам → зафиксировать Core Web Vitals как базовую линию.
- Step 2: Commit
git add docs/superpowers/specs/2026-07-18-seo-offpage-checklist.md
git commit -m "docs(seo): add off-page checklist"
Self-Review
Spec coverage:
- Фаза 0 (robots + lastmod) → Task 1 ✓
- Фаза 1 (BreadcrumbList, проверка BlogPosting) → Task 2 ✓ (BlogPosting уже эмитится; отдельная правка не нужна, проверяется в Step 3 наличием 2 блоков)
- Фаза 2 (backfill description+tags, форвард) → Tasks 3–5 ✓ (форвард — через переиспользование backfill вместо Go-правки, отклонение задокументировано)
- Фаза 3 (тег-хабы в sitemap) → Task 5 Step 5 ✓
- Фаза 4 (OG) → вне scope, спец. отметка ✓
- Off-page → Task 6 ✓
- FAQPage/title/домен — исключены (Global Constraints) ✓
Placeholder scan: нет TBD/TODO; весь код приведён полностью. ✓
Type consistency: parseFrontmatter возвращает {data, body, fmRaw, hasFm} — используется одинаково в upsertFrontmatter, needsBackfill, main. sanitizeTags/extractJson/buildPrompt — сигнатуры совпадают между Task 3/4 и тестами. ✓
Отклонение от spec: §2.2 (правка Go-генератора) заменена на переиспользование seo-backfill.js как форвард-механизма — DRY, без пересборки бинаря. Задокументировано в Task 5.