Перейти к основному содержимому

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 (preset classic options — добавить блок 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, в т.ч. обёрнутый в ```json fences; кидает Error('no JSON') если не найден.
    • sanitizeTags(rawTags) -> string[] — фильтрует по словарю, дедуп, сохраняет порядок; если пусто → ['go'].
    • needsBackfill(data) -> booleantrue, если у поста нет непустого 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.